<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Cracked Chefs by Oluwaferanmi Adeniji]]></title><description><![CDATA[Battle-tested Coding patterns, Javascript wizardry, System Design, Product Engineering &amp; Management, and architectural secrets.]]></description><link>https://crackedchefs.devferanmi.xyz</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1753012104331/4bc137e4-cc0b-4fb5-bcca-a08e541d80da.jpeg</url><title>Cracked Chefs by Oluwaferanmi Adeniji</title><link>https://crackedchefs.devferanmi.xyz</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 19 Aug 2026 17:38:07 GMT</lastBuildDate><atom:link href="https://crackedchefs.devferanmi.xyz/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[My journey into frontend engineering and what I wish I knew earlier]]></title><description><![CDATA[Introduction
I'm a software engineer who builds systems that solve real problems. At Moniepoint, I've contributed to platforms serving millions of users, shipping business loan flows, event tracking s]]></description><link>https://crackedchefs.devferanmi.xyz/my-journey-into-frontend-engineering-and-what-i-wish-i-knew-earlier</link><guid isPermaLink="true">https://crackedchefs.devferanmi.xyz/my-journey-into-frontend-engineering-and-what-i-wish-i-knew-earlier</guid><dc:creator><![CDATA[Oluwaferanmi Adeniji]]></dc:creator><pubDate>Mon, 04 May 2026 20:55:48 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5d5009ed2db7c7fb3cd7cf28/0642cf04-ac27-4867-8862-b430a9c48220.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>Introduction</strong></p>
<p>I'm a software engineer who builds systems that solve real problems. At Moniepoint, I've contributed to platforms serving millions of users, shipping business loan flows, event tracking services, and workflow automation that simplifies back-office operations.</p>
<p>My focus is on Angular, React, and component architecture that scales across teams. What sets my approach apart is pairing technical depth with user-centred thinking, so every feature genuinely improves the experience for business owners, field officers, or internal teams. I've also grown from someone who rarely spoke up into someone who leads cross-team collaboration, writes documentation other teams adapt, and takes ownership of complex challenges that span multiple products.</p>
<p><strong>Into Frontend Engineering</strong>  </p>
<p>My path into frontend engineering began in 2017 with a simple discovery: HTML Code Play, an app that allowed me to write HTML and see it render instantly on my phone. That magical moment of code becoming visual sparked the curiosity that drives my work today.</p>
<p>I started with the sample template, then gradually explored more HTML tags, fascinated by how a few lines of code could create something tangible on the screen. It felt like having a superpower, the ability to transform ideas into interactive experiences with nothing but text and logic. Eventually, I wanted to build something real with these new skills, so I created a simple Computer-Based Test (CBT) app for students to take mock exams. I shared it with a few friends and, for the first time, felt the thrill of building for actual users.</p>
<p><strong>Lessons from the Trenches</strong></p>
<p><strong>– The JavaScript Rabbit Hole</strong><br />The earliest challenges I faced was understanding why JavaScript was necessary in the first place. I couldn't wrap my head around why people used libraries like jQuery, or why abstraction was even important. It felt easier to write everything in a single function and reuse it everywhere. I also questioned the need for frontend frameworks like React or Angular, if I could manipulate the DOM directly with plain JavaScript, why bother learning something else?</p>
<p>Another area I struggled with was asynchronous programming. Concepts like callbacks, promises, and the event loop were confusing at first. But over time, through practice and experimentation, it all started to click. Looking back now, understanding asynchronous behavior is an important milestone in my frontend journey.</p>
<p><strong>– CSS Struggles</strong></p>
<p>CSS was another major hurdle for me. Flexbox and Grid layouts felt especially overwhelming, and at some point, I found myself avoiding layout-related tasks entirely. But I've always been drawn to difficult challenges, so I sat down one day and made a decision to figure it out. I watched tutorials, read documentation, and built small layouts until the concepts became second nature. Since then, working with CSS has become a joy, and I now approach every layout in terms of flex containers, grid systems, columns, and rows.</p>
<ol>
<li><strong>Levelling Up: From Developer to Engineer – Thinking in Components</strong></li>
</ol>
<p>Using HTML/CSS/JavaScript without a component-based structure is always a struggle. I experienced this firsthand when building early web interfaces, repetitive designs across pages weren't easily reusable, forcing me to copy and duplicate pieces of HTML/CSS code everywhere, leading to codebases that became bloated, heavy, and slow.</p>
<p>Learning React and getting introduced to components made me rethink my entire design approach. At Moniepoint, this shift became crucial when working on our loan product interfaces. Instead of building separate, monolithic pages for loan requests, approvals, and agreements, I developed reusable components like loan status indicators, approval timelines, composable layouts and detail viewers that could be composed across different flows. This component-first approach not only reduced development time but ensured consistency across our financial products, making it easier for users to navigate between different loan features with familiar interface patterns. This development pattern made it easier for other loan products like Overdrafts to quickly prototype and go live in a shorter time.</p>
<p><strong>– Knowing your crafts</strong></p>
<p>Becoming a software engineer goes beyond just typing functions and building beautiful landing pages or web applications. It meant going in depth into how the tools work, how the browser worked, how the JavaScript engines work, software engineering principles and its application to what I’m building, how browsers compile my code, how the DOM works, and how all the frameworks I use actually work and detect state changes. I read a couple of books and blogs, like crafting interpreters, computer algorithms, V8 engine, etc. This significantly improved how I thought about implementing features and fixes.</p>
<p><strong>– Testing: From Painful to Powerful</strong></p>
<p>Over the years, I’ve learnt that even the task you spent a lot of time working on could have defects, and a way to quickly catch defects is through testing, which could be unit tests, regression tests, integration, end-to-end or visual regression testing.</p>
<p>As a frontend engineer, I learnt that the brain builds a sense of familiarity between a Figma design and your implementation. This greatly affects my sense of noticing some tiny details, because the brain fills in for it. I combat this by practising visual regression tests; I put designs right beside implementation as much as possible. I also take the design implementation component by component from the smallest up. By doing this, I’m building my attention to detail, soaking in more as I do more complex implementations, and once I bring all the components together, it all looks 100% like the Figma design.</p>
<p>I’ve also preferred test-driven development. It provides and lays the foundation for a better implementation of features, ensuring that I write proper test cases and not that tests will be an afterthought or written to pass. When implementing features, if you write a test first, you’re writing it when you’ve not been bothered with implementation, or tired from the grueling work of implementation, it ensures you have the right frame of mind to write good test cases for the features.</p>
<p><strong>– Performance Matters</strong><br />Practice really does make perfect. As I learned more about browsers and JavaScript engines, I came to understand a painful but important truth. Browsers can be very memory-intensive, and web pages can get really slow. Sometimes the issue is with the Chrome engine, sometimes it's the browser itself, and other times it’s just how the webpage was built.</p>
<p>But here is the thing. You cannot optimize what you cannot measure. If you do not know how long your page takes to load or how big your bundle sizes are, then you are basically flying blind. You need proper tools to guide your performance decisions.</p>
<p>I use a combination of tools to track and measure performance. Lighthouse is great for quick audits. Web Vitals helps with real-world metrics. The Webpack Bundle Analyzer gives insights into what is bloating your build. I also use extensions like import cost to check the size of libraries I am adding, and I rely on the Performance API to dive deeper into how the browser is actually handling everything.</p>
<p>There was a time when performance became a real challenge for me. I built a web app that made a lot of fetch requests and cached the results in the user's browser. On the surface, it looked fine, but once people started using it more, the problems became clear. It was slow, it lagged on some devices, and it suffered from concurrency issues. Without proper tooling, I would have kept guessing what was wrong or doing endless back-and-forth testing on specific devices.</p>
<p>I took a step back and ran some performance profiling. I realized the bottleneck was how and when I was caching the data. So I redesigned the flow. I introduced a locking mechanism to prevent multiple fetches from running at once, used asynchronous fetch patterns to improve responsiveness, implemented queues to better manage load, and added a periodic caching strategy instead of caching everything immediately.</p>
<p>That single experience taught me the value of measuring first, diagnosing properly, and then fixing with intention. Performance is not about tweaking random things until something works. It is about understanding how your code behaves, how the browser reacts, and how your choices affect real users.</p>
<ol>
<li><strong>Beyond Code: The Soft Side of Engineering</strong></li>
</ol>
<p><strong>– Communicating with Designers and PMs</strong></p>
<p>I grew up as an introvert. I was the kind of engineer who quietly got tasks done, unblocked teammates when needed, and kept to myself. While I communicated well within my immediate team, I rarely engaged beyond that. Even though I had ideas and insights, I often struggled to speak up during engineering sessions, deep dives, or sprint planning meetings.</p>
<p>That changed "<strong>dramatically</strong>”.</p>
<p>A turning point for me was during my first quarterly review at Moniepoint. My manager at the time wrote a simple but powerful comment: "Feranmi should speak more. He has a lot to contribute." I took that feedback seriously. It made me realize that doing good work wasn't enough on its own. If I wanted to grow and truly make an impact, I needed to start contributing more vocally and visibly.</p>
<p>So I began to challenge myself. I started speaking at conferences, developer events, engineering meetings, and deep-dive sessions. I also volunteered to join the engineering meeting committee at Moniepoint. These meetings are a platform for exploring new technologies, reviewing research papers, and learning about products other teams are building. I took on the responsibility of coordinating the sessions, working with teams and speakers, and hosting the events.</p>
<p>When I first joined, these sessions had about 70 attendees. Today, we've seen over 200 attendees on some occasions. An important highlight of the journey was hosting our CTO, Felix Ike, as a guest speaker. It was a surreal moment. Just two years prior, I could barely speak in meetings of ten people, and now I was facilitating a session with over 250+ engineers in attendance.</p>
<p>Feedback at Moniepoint is clear and constructive. Over the past year, I’ve received encouraging notes from my manager, recognizing how much more vocal and engaged I’ve become. I’ve been commended for simplifying complex topics and helping keep the team aligned.</p>
<p>Now, I see communication as an integral part of engineering. Sharing ideas, asking questions, unmuting your mic, or even turning on your camera during meetings are small but meaningful ways to show up and be present for your team. I’ve grown into a more collaborative, confident engineer, and that has made all the difference.</p>
<p><strong>– Mentorship and Reviews</strong></p>
<p>Over the past years, I’ve grown significantly in both giving and receiving code reviews. My approach to writing code has evolved. I now focus on clarity, scalability, and elegance. This shift didn’t happen overnight. It came through countless peer reviews, self-reflection, and thoughtful feedback from the Web Architect on my team.</p>
<p>One habit I’ve developed is revisiting my older code, sometimes from months or even years back and refactoring it with fresh eyes. It’s a practice that helps me measure my growth and challenge myself to find cleaner, more efficient solutions.</p>
<p>Giving reviews is something I used to shy away from. I often doubted whether I had anything valuable to contribute. But I’ve come to realize that reviewing code is a good way to deepen your understanding. When I look at someone else’s implementation, I try to understand not just what they wrote, but why they wrote it that way. This mindset has sharpened my own thought process and taught me to be more intentional in my suggestions.</p>
<p>Providing clear, constructive feedback has become part of my everyday workflow. It has improved my communication skills, my confidence as an engineer, and my ability to advocate for quality standards across the team. Reviewing code isn't just about finding bugs; it's about building a shared understanding of what good engineering looks like.</p>
]]></content:encoded></item><item><title><![CDATA[ LLM Cost Control in Production: Multi-Level Caching for AI Products ]]></title><description><![CDATA[There's a moment every AI product builder hits, usually around week three of production traffic, where the OpenAI dashboard stops being exciting and starts being alarming. The spend curve is vertical.]]></description><link>https://crackedchefs.devferanmi.xyz/llm-cost-control-in-production-multi-level-caching-for-ai-products</link><guid isPermaLink="true">https://crackedchefs.devferanmi.xyz/llm-cost-control-in-production-multi-level-caching-for-ai-products</guid><dc:creator><![CDATA[Oluwaferanmi Adeniji]]></dc:creator><pubDate>Thu, 26 Mar 2026 09:30:00 GMT</pubDate><content:encoded><![CDATA[<p>There's a moment every AI product builder hits, usually around week three of production traffic, where the OpenAI dashboard stops being exciting and starts being alarming. The spend curve is vertical. The unit economics don't work. And the painful realisation sets in that "call the model" is not a cost model — it's a billing surprise waiting for enough users.</p>
<p>I've built three AI-backed products — TaxLens (income tax from bank statements), TrustRail (BNPL underwriting), and BuffByte (AI content optimisation for creators) — and across all three I've had to think carefully about where model calls happen, how often they happen, and how to avoid paying for the same computation twice.</p>
<p>This article is the distillation of those decisions, it's the specific layers I use, the conditions under which each one applies, and the honest evaluation of what each buys you.</p>
<p>Let's dig in.</p>
<hr />
<h2>Why Caching Is Different for AI Products</h2>
<p>In a traditional API, caching is about latency: you cache to serve responses faster.(sometimes too about costs, one could argue time costs money too)<br />In an AI product, caching is primarily about cost(monetary): you cache to avoid paying OpenAI for a response you've already paid for.</p>
<p>This distinction changes the calculus. For a fast traditional API response (~20ms), you might cache results that are only marginally expensive to recompute. For an LLM call ($0.06–0.15 per request, 2–5 seconds), you cache aggressively because the cost of a cache miss is substantial.</p>
<p>It also changes the invalidation strategy. Traditional caches invalidate on data changes. LLM response caches must also account for prompt changes, model version changes, and the inherent non-determinism of model outputs. A cache that returns a stale LLM response because the underlying data changed and the cache key didn't capture it is a silent correctness bug.</p>
<p>The design question is not "should I cache?" — you should. The design question is "at which level do I cache, with which key, and with which TTL?"</p>
<hr />
<h2>Level 1: The Gate as a PreComputation Firewall</h2>
<p>The first and most impactful caching decision isn't a response cache at all — it's the gate pattern described in an earlier article in this series. But it belongs here because its primary function is cost avoidance.</p>
<p>TaxLens's <code>runPipeline</code> runs a cheap gate model before the expensive analysis model:</p>
<pre><code class="language-typescript">// Tier 1: cheap model (~$0.002)
const gate = await llmClient.structured({
  tier: 'gate',
  model: env.OPENAI_GATE_MODEL,
  // ...
});

if (!gate.data.valid) {
  // Pipeline terminates. Analysis model never fires.
  return;
}

// Tier 2: expensive model (~$0.06–0.08)
const analysis = await llmClient.structured({
  tier: 'analysis',
  model: env.OPENAI_ANALYSIS_MODEL,
  // ...
});
</code></pre>
<p>At a 15% invalid document rejection rate, this saves ~13% on total model spend with no change in output quality for valid documents. At a 30% rejection rate — which is plausible for a consumer product where users experiment with non-bank-statement PDFs — it saves ~26%.</p>
<p>The gate also prevents one category of abuse: a user uploading documents in a rapid loop to test the system would trigger a gate failure on non-bank-statement documents without ever burning expensive analysis credits. This isn't a complete abuse prevention system, but it's a meaningful cost control at minimal engineering cost.</p>
<p><strong>Cache key consideration:</strong> The gate result is not cached by document content — PDFs are large, content-hashing is expensive, and the same user might legitimately re-upload after fixing a problem. The gate is a per-submission cost, not a per-unique-document cost. For the TaxLens use case (one bank statement per tax process), this is the right model.</p>
<hr />
<h2>Level 2: Exact-Match Response Caching</h2>
<p>For queries where the same input is likely to produce the same useful output, exact-match response caching is the most straightforward win.</p>
<p>The Ajala AI SDK (my open-source multi-provider AI integration library) implements this as a first-class feature. When <code>cachable: true</code> is set, the SDK computes a cache key from the hash of <code>system prompt + user prompt + provider + model</code> and returns the cached response if a matching entry exists:</p>
<pre><code class="language-typescript">const result = await ai.prompt('Get weather in {{CITY}}', {
  expectJson: true,
  jsonStructure: { temp: 'number', condition: 'string' },
  validateJSON: true,
  cachable: true,
}, { CITY: 'Lagos' });
</code></pre>
<p>If this exact prompt, with this exact variable substitution, against this exact model, has been called within the TTL window, the SDK returns the cached response. No API call. No tokens consumed.</p>
<h3>Where This Works Well</h3>
<p><strong>Classification tasks.</strong> BuffByte's content analyser classifies creator content against a taxonomy of topics, tones, and engagement patterns. A specific YouTube title or hook will receive identical analysis regardless of how many users trigger the analysis. Exact-match caching means the second user to submit the same content gets an instant response at zero incremental cost.</p>
<p><strong>Reference data queries.</strong> "What are the NTA 2025 income tax bands?" has a stable answer that doesn't change between tax years. The TaxLens chat system could cache this kind of reference query and serve it from cache for the duration of the tax year.</p>
<p><strong>Validation queries.</strong> The gate call in TaxLens asks "is this document a valid Nigerian bank statement?" The answer for a given document doesn't change between users. If two users upload the same bank statement file (rare, but possible in a shared-access product), exact-match caching would serve the second validation instantly.</p>
<h3>Where Exact-Match Fails</h3>
<p><strong>User-specific queries.</strong> "Why is my tax liability ₦240,000?" is specific to the user's <code>computation</code> object. Two users with different incomes will have different context in their prompts even if the question is identical. Exact-match caching on the full prompt + context is unlikely to produce cache hits because the context varies per user.</p>
<p><strong>Long prompts.</strong> If the cache key is a hash of a 20KB prompt (a bank statement CSV embedded in the prompt), the hash computation itself is cheap but the cache may be too specific to produce hits. Segment the cache key: hash the static parts (system prompt, model) separately from the dynamic parts (user content), and cache based on the static + a meaningful key from the dynamic part.</p>
<hr />
<h2>Level 3: Process-Level Response Reuse (Conversation Threading)</h2>
<p>TaxLens's pipeline makes two sequential model calls — gate, then analysis. Both calls process the same PDF. Without any mechanism to avoid redundancy, the PDF is sent to the model twice.</p>
<p>OpenAI's Responses API supports <code>previous_response_id</code>: a new call can continue from a previous response, and the API server reuses the cached prior context. TaxLens uses this directly:</p>
<pre><code class="language-typescript">// Tier 2 continues from Tier 1's response
const analysis = await llmClient.structured({
  tier: 'analysis',
  previousResponseId: gate.responseId,  // ← continues the conversation
  // No PDF attached — the model has it from the prior turn
  // ...
});
</code></pre>
<p>When <code>previous_response_id</code> is set, the model's context from the prior response is reused server-side. The PDF doesn't need to be re-sent. The input tokens for the second call are reduced to the new user prompt and system prompt only.</p>
<p>The chat tier chains further:</p>
<pre><code class="language-typescript">// Chat questions continue from the analysis response
const chat = await llmClient.structured({
  tier: 'chat',
  previousResponseId: process.analysisResponseId,
  user: `\({context}\n\nQUESTION: \){question}`,
  // ...
});
</code></pre>
<p>Each chat question pays for: the system prompt, the context block (the computed tax figures — a few hundred tokens), and the user's question. It does not re-pay for the bank statement content, the gate verdict, or the analysis output — those are in the cached prior response context.</p>
<p>For a user who asks 10 follow-up questions, this difference is substantial. Without threading, each question re-sends the full context. With threading, the marginal cost per question is just the question tokens and the answer tokens.</p>
<p><strong>Condition:</strong> This only works with providers that support server-side response caching and conversation threading. OpenAI's Responses API supports it. Not all providers do. When building with Ajala, check provider capabilities before designing around this pattern.</p>
<hr />
<h2>Level 4: Prompt Structure for Provider-Side Cache Hits</h2>
<p>Both Anthropic and OpenAI offer server-side prompt caching: if a call's prompt shares a long prefix with a recent call to the same model, the provider charges a reduced rate (often 50–90% less) for the cached prefix tokens.</p>
<p>The key constraint: the cached prefix must be <em>identical</em> across calls. Variable content must come <em>after</em> the stable prefix.</p>
<p>This changes how you structure prompts. The wrong structure:</p>
<pre><code class="language-typescript">// Bad: user-specific content mixed into the prefix
const system = `You are TaxLens. The user's gross income is ₦${grossAnnualKobo}. 
Answer only personal income tax questions under the NTA 2025.`;
</code></pre>
<p>Every user has a different <code>grossAnnualKobo</code>. Every call has a different system prompt. No provider-side cache hits.</p>
<p>The right structure:</p>
<pre><code class="language-typescript">// Good: stable instructions as prefix, variable context in the user turn
const system = `You are TaxLens, a grounded assistant for Nigerian PERSONAL income tax 
under the Nigeria Tax Act 2025 (NTA 2025), effective 1 January 2026.

HARD RULES:
- Answer ONLY personal income tax questions under the NTA 2025...
- You may ONLY explain the computed numbers provided to you in the CONTEXT below...
- Every substantive answer MUST cite the relevant NTA 2025 section...`;

// Variable context goes in the user turn
const user = `CONTEXT — the only numbers you may discuss:\n${JSON.stringify(computation)}
\n\nQUESTION: ${question}`;
</code></pre>
<p>The system prompt is now identical across every chat call from every user. The provider caches it after the first call. Subsequent calls from any user hit the cache for the static portion. Only the variable user turn is charged at full rate.</p>
<p>This is the structure TaxLens uses in <code>ai.service.ts</code>. The <code>SYSTEM</code> constant is defined once at module level — no runtime interpolation, no user-specific content. All variable content goes into the user message.</p>
<h3>How Much Does This Save?</h3>
<p>System prompts of 500–1,000 tokens at gpt-4o pricing (~\(0.0025/1K input tokens) save ~\)0.001–0.002 per call with a 70% cache hit. At 10,000 calls/day, that's $10–20/day — not enormous, but not trivial. For the analysis system prompt (which is longer and contains classification guidance), the saving is proportionally larger.</p>
<p>More importantly: prompt structure for cache hits is zero-cost to implement once you're aware of the constraint. It's one of the few cost optimisations with no trade-off.</p>
<hr />
<h2>Level 5: Background Processing as a Demand Smoother</h2>
<p>TrustRail's <code>statementAnalysisJob</code> doesn't respond to user requests in real time. It processes up to 10 pending applications per minute on a cron schedule:</p>
<pre><code class="language-typescript">const pendingApplications = await Application.find({ status: 'PENDING_ANALYSIS' })
  .sort({ submittedAt: 1 })
  .limit(10);
</code></pre>
<p>This is a form of demand shaping. A sudden surge of 50 simultaneous application submissions doesn't produce 50 simultaneous GPT-4o calls — it produces a queue that drains at a controlled rate over 5 minutes. The cost curve stays linear and bounded regardless of submission spikes.</p>
<p>For interactive products (TaxLens, BuffByte), this pattern doesn't apply directly — users are waiting for results. But the principle generalises: any AI computation that doesn't need to happen in the request path should be deferred to a background job. Asynchronous content analysis for a creator platform, batch report generation, digest emails — these are all better suited to a queue than to inline model calls.</p>
<p><strong>The cost property:</strong> A queue with a throughput ceiling creates a predictable cost ceiling. If the ceiling is 10 calls/minute and each call costs \(0.08, the maximum cost is \)4.80/hour regardless of submission volume. Without the ceiling, cost is fully variable with user behaviour.</p>
<hr />
<h2>The Token Budget: Constraining the Chat Tier</h2>
<p>The chat tier is the highest-risk tier for runaway costs. A single engaged user sending 30 questions in a session is 30 model calls. At $0.01–0.05 per call depending on answer length, that's manageable per user. At 1,000 concurrent users all doing the same thing, it's not.</p>
<p>Practical controls:</p>
<p><strong>Per-session question limits</strong> — TaxLens processes are scoped to a single tax computation. The session naturally terminates when the user leaves or the process expires. But for open-ended chat products, a per-session or per-day question limit is a meaningful cost control. Implement it at the application layer before it becomes necessary at the billing layer.</p>
<p><strong>Context compression</strong> — The <code>buildContext</code> function in TaxLens passes <code>JSON.stringify(computation)</code> as the user context. For a typical tax computation, this is 1–3KB. If context grows (historical data, multiple analyses), it should be summarised rather than passed in full. A summarised context that's 500 tokens instead of 2,000 tokens changes the per-question cost from \(0.06 to \)0.015 — a 75% reduction.</p>
<p><strong>Response length constraints</strong> — System prompts that say "keep answers concise" are not just UX guidance — they reduce output tokens. A 500-token answer costs more than a 100-token answer. For a constrained domain (personal income tax questions, not open-ended conversation), short answers are often better answers anyway.</p>
<hr />
<h2>Monitoring as a Prerequisite</h2>
<p>None of the above can be optimised without visibility into what's actually happening. TaxLens's <code>llm_audit</code> collection records <code>inputTokens</code>, <code>outputTokens</code>, <code>latencyMs</code>, <code>tier</code>, <code>model</code>, and <code>circuitState</code> for every model call.</p>
<p>This makes cost questions answerable:</p>
<ul>
<li><p>"Which tier is consuming the most tokens per day?" — query <code>llm_audit</code>, group by <code>tier</code>, sum <code>inputTokens + outputTokens</code></p>
</li>
<li><p>"Is any user or process burning disproportionate chat turns?" — group by <code>code</code>, count <code>tier: 'chat'</code>, surface outliers</p>
</li>
<li><p>"Are cache hits registering on the provider's side?" — compare <code>inputTokens</code> for chat calls with <code>previousResponseId</code> vs. without; a significant difference confirms caching is working</p>
</li>
<li><p>"Is the circuit breaker affecting cost?" — count calls with <code>circuitState: 'open'</code>; if non-zero during business hours, adjust the failure threshold</p>
</li>
</ul>
<p>The <code>promptHash</code> field (SHA-256 of system + user prompts) enables deduplication analysis: calls with the same hash that produce different <code>outputTokens</code> indicate model non-determinism; calls with the same hash that could be served from a response cache identify caching opportunities you haven't implemented yet.</p>
<hr />
<h2>Putting It Together: Cost Profile for TaxLens</h2>
<p>A complete TaxLens analysis + one chat question, with all caching layers applied:</p>
<table>
<thead>
<tr>
<th>Step</th>
<th>Model</th>
<th>Tokens (estimated)</th>
<th>Cost</th>
</tr>
</thead>
<tbody><tr>
<td>Gate call</td>
<td>gpt-4o-mini</td>
<td>2K input (PDF), 50 output</td>
<td>~$0.0004</td>
</tr>
<tr>
<td>Analysis call (threading)</td>
<td>gpt-4o</td>
<td>1K input (prompt only, no PDF re-send), 500 output</td>
<td>~$0.016</td>
</tr>
<tr>
<td>Chat call (threading + cached prefix)</td>
<td>gpt-4o</td>
<td>300 input (context + question, prefix cached), 150 output</td>
<td>~$0.004</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td></td>
<td></td>
<td><strong>~$0.021</strong></td>
</tr>
</tbody></table>
<p>Without caching (no threading, no prefix caching, no gate):</p>
<ul>
<li><p>Gate: skipped (1 analysis call does the work)</p>
</li>
<li><p>Analysis: 20K input tokens (PDF + full prompt), 500 output → ~$0.055</p>
</li>
<li><p>Chat: 5K input tokens (full re-sent PDF context + question), 150 output → ~$0.015</p>
</li>
<li><p><strong>Total: ~$0.070</strong></p>
</li>
</ul>
<p>The difference: ~\(0.049 per complete user session, or ~70% cost reduction from caching layers. At 1,000 sessions/day, that's ~\)49/day saved ($1,470/month) from architecture decisions that don't change the user experience at all.</p>
<hr />
<h2>Trade-offs</h2>
<p><strong>Caching introduces correctness risk.</strong> A cached LLM response that was correct when computed may be incorrect after a model update or a prompt change. Response caches must be invalidated when prompts change. The Ajala SDK's cache key includes the model version for this reason — a model upgrade automatically invalidates cached responses.</p>
<p><strong>Conversation threading creates longer dependency chains.</strong> If the analysis response that subsequent chat turns depend on is deleted (by a reaper job clearing old processes), those chat turns can no longer thread correctly. TaxLens handles this by including the full computation context in every chat user turn as a fallback — the threading is an optimisation, not a requirement.</p>
<p><strong>Background queues don't suit interactive workloads.</strong> The throughput ceiling that makes queues cost-predictable also makes them latency-unpredictable. If response time matters to the user, background queuing isn't the right shape.</p>
<p><strong>Provider-side caching has minimum length requirements.</strong> OpenAI's prompt caching applies to prompts above a certain length threshold (currently 1,024 tokens). Short system prompts don't benefit. This is only relevant for very terse prompts — in practice, most production system prompts with meaningful instructions clear this threshold.</p>
<hr />
<h2>The Mental Model</h2>
<p>LLM calls have three cost dimensions: <em>how often they're made</em>, <em>how many tokens they use</em>, and <em>whether you pay full price or cached price</em>. Every caching layer addresses one of these dimensions.</p>
<p>The gate addresses frequency (some calls are simply never made). Response caching and background queues address frequency from a different angle (calls are deduplicated or rate-limited). Conversation threading and prefix caching address token cost (you pay for less content per call). Provider-side prompt caching addresses per-token price (you pay a fraction of the normal rate for cached portions).</p>
<p>No single layer is sufficient. A product with a perfect gate but no threading still pays full token cost for every chat question. A product with threading but no gate pays full cost for every invalid document submission. The layers compound.</p>
<p><strong>Design the cost architecture before the first user arrives. The bill does not wait for you to be ready for it.</strong></p>
]]></content:encoded></item><item><title><![CDATA[ Feature-Sliced Design in Practice]]></title><description><![CDATA[Most articles about Feature-Sliced Design show you the theory. The layer diagram. The import direction rules. The terminology (slices, segments, public API). Then they show you a contrived example wit]]></description><link>https://crackedchefs.devferanmi.xyz/feature-sliced-design-in-practice</link><guid isPermaLink="true">https://crackedchefs.devferanmi.xyz/feature-sliced-design-in-practice</guid><dc:creator><![CDATA[Oluwaferanmi Adeniji]]></dc:creator><pubDate>Thu, 12 Mar 2026 08:00:00 GMT</pubDate><content:encoded><![CDATA[<p>Most articles about Feature-Sliced Design show you the theory. The layer diagram. The import direction rules. The terminology (slices, segments, public API). Then they show you a contrived example with three components and call it a day.</p>
<p>That's not what this is.</p>
<p>I've applied FSD across three different products at genuinely different scales and domains: TrustRail (a B2B BNPL platform), Medcord (a hospital management system), and WordShot (a real-time multiplayer word game). All three follow the same structural rules. All three look different. The interesting part is understanding why — and where the pattern holds, where it bends, and when you shouldn't use it at all.</p>
<p>Let's get cracking,</p>
<hr />
<h2>The Problem It Solves</h2>
<p>Here's the folder structure I used to fall into on every project:</p>
<pre><code class="language-plaintext">src/
├── components/     (47 files)
├── hooks/          (23 files)
├── utils/          (31 files)
├── types/          (18 files)
└── pages/          (12 files)
</code></pre>
<p>This feels tidy at the start. It's a disaster at scale.</p>
<p>Want to understand how multiplayer works in WordShot? You grep <code>/components</code> for multiplayer-related components, <code>/hooks</code> for multiplayer state, <code>/utils</code> for multiplayer helpers, and <code>/types</code> for the WebSocket event shapes. Those four folders tell you <em>what type of thing</em> each file is — they tell you nothing about <em>which feature</em> it belongs to.</p>
<p>Want to add a new feature? You touch files in four folders simultaneously, creating coupling between unrelated features at the filesystem level.</p>
<p>Want to remove the demo mode? You grep for <code>demo</code> across all four folders, hope you found everything, delete what you found, run the build, fix the broken imports you missed, repeat.</p>
<p>The folder-by-type structure is fine for small projects. The moment a project has more than about four independent features, it actively works against you.</p>
<hr />
<h2>The Core Rule</h2>
<p>Feature-Sliced Design's core rule can be stated in one sentence:</p>
<blockquote>
<p><strong>A feature owns everything it needs to function. Code that's needed by two or more features lives in</strong> <code>shared/</code><strong>. Code that's needed by one feature lives inside that feature.</strong></p>
</blockquote>
<p>Corollaries:</p>
<ul>
<li><p>A feature never imports from another feature</p>
</li>
<li><p>A feature can import from <code>shared/</code></p>
</li>
<li><p><code>shared/</code> never imports from any feature</p>
</li>
<li><p>Code starts in the feature. It gets promoted to <code>shared/</code> when the second consumer arrives — not before</p>
</li>
</ul>
<p>That's it. Everything else — the naming conventions, the sub-folder structure, the decision about whether to use sub-features — is derived from this rule applied to your specific domain.</p>
<hr />
<h2><a href="https://github.com/spiderocious/trustrail-frontend">TrustRail</a>: FSD at the Small End</h2>
<p>TrustRail's <a href="https://github.com/spiderocious/trustrail-frontend">frontend</a> is a React application with five features. It's the simplest case — a clean, unambiguous application of the pattern.</p>
<pre><code class="language-plaintext">src/
├── features/
│   ├── auth/
│   │   ├── api/
│   │   │   ├── use-login.ts
│   │   │   └── use-register.ts
│   │   ├── guards/
│   │   │   ├── auth-guard.tsx
│   │   │   └── guest-guard.tsx
│   │   ├── helpers/
│   │   │   ├── nigerian-banks.ts
│   │   │   ├── token-storage.ts
│   │   │   └── validation.ts
│   │   ├── providers/
│   │   │   └── auth-provider.tsx
│   │   ├── screen/
│   │   │   ├── login-screen.tsx
│   │   │   ├── register-screen.tsx
│   │   │   └── parts/
│   │   │       ├── business-details-step.tsx
│   │   │       └── document-upload-step.tsx
│   │   └── auth.routes.tsx
│   │
│   ├── dashboard/          (overview metrics, application summary)
│   ├── applications/       (list, details, approve/decline)
│   ├── trust-wallets/      (BNPL product configuration)
│   └── public/             (the applicant-facing form)
│
└── shared/
    ├── constants/
    │   ├── api.ts
    │   └── routes/routes.ts
    ├── helpers/
    │   └── api-client.ts
    └── types/
        ├── auth.ts
        └── index.ts
</code></pre>
<p>Each feature contains its complete stack: API hooks (<code>use-login.ts</code>), screens (<code>login-screen.tsx</code>), component parts (<code>business-details-step.tsx</code>), helpers (<code>nigerian-banks.ts</code>), providers (<code>auth-provider.tsx</code>), and routes (<code>auth.routes.tsx</code>). Everything the <code>auth</code> feature needs to function is inside the <code>auth</code> folder.</p>
<p><code>shared/</code> is minimal and intentional. It contains three things: API constants, the API client instance (used by every feature's API hooks), and base types. Nothing that belongs to a single feature.</p>
<p>Notice what's <em>not</em> in <code>shared/</code>: no UI components, no business logic, no screens, no feature-specific helpers. The Nigerian banks list (<code>nigerian-banks.ts</code>) is in <code>auth/helpers/</code> because only the registration form uses it. If another feature ever needed to display a bank selector, that file would move to <code>shared/helpers/</code>. It hasn't moved yet. It won't move until that second consumer exists.</p>
<h3>The Naming Convention</h3>
<p>Every file follows a consistent naming pattern across the entire project:</p>
<ul>
<li><p>API hooks: <code>use-{resource}.ts</code> — <code>use-login.ts</code>, <code>use-applications-list.ts</code>, <code>use-trust-wallet-details.ts</code></p>
</li>
<li><p>Screens: <code>{resource}-screen.tsx</code> — <code>login-screen.tsx</code>, <code>application-details-screen.tsx</code></p>
</li>
<li><p>Screen parts: <code>{part}-{type}.tsx</code> — <code>business-details-step.tsx</code>, <code>action-modal.tsx</code></p>
</li>
<li><p>Providers: <code>{name}-provider.tsx</code> — <code>auth-provider.tsx</code></p>
</li>
<li><p>Guards: <code>{condition}-guard.tsx</code> — <code>auth-guard.tsx</code>, <code>guest-guard.tsx</code></p>
</li>
<li><p>Routes: <code>{feature}.routes.tsx</code> — <code>auth.routes.tsx</code>, <code>applications.routes.tsx</code></p>
</li>
</ul>
<p>This convention means a new developer navigating the codebase can predict file locations before finding them. "Where is the hook for fetching application details?" — <code>features/applications/api/use-application-details.ts</code>. No search required.</p>
<hr />
<h2><a href="http://github.com/spiderocious/medcord-app">Medcord</a>: FSD at the Large End</h2>
<p>Medcord is a hospital management system. It handles patient registration, EMR records, lab orders, staff management, asset tracking, and workspace administration. It's substantially more complex than TrustRail — the kind of project where a poor folder structure becomes actively painful.</p>
<p>The top-level feature count is nine. But the more important distinction from TrustRail is that most features contain <em>sub-features</em> — a second level of the same pattern applied recursively.</p>
<pre><code class="language-plaintext">src/
├── features/
│   ├── auth/
│   │   ├── api/
│   │   ├── features/
│   │   │   ├── forgot-password/
│   │   │   ├── login/
│   │   │   │   ├── parts/
│   │   │   │   │   ├── login-form.tsx
│   │   │   │   │   └── two-fa-step.tsx
│   │   │   │   └── screen/
│   │   │   │       └── login-screen.tsx
│   │   │   ├── register/
│   │   │   ├── reset-password/
│   │   │   └── setup-2fa/
│   │   └── shared/
│   │       └── parts/
│   │           └── auth-layout.tsx
│   │
│   ├── patients/
│   │   ├── features/
│   │   │   ├── patient-list/
│   │   │   ├── patient-profile/
│   │   │   ├── patient-register/
│   │   │   ├── patient-admitted/
│   │   │   ├── patient-checkedin/
│   │   │   └── patient-transfers/
│   │   └── shared/
│   │       └── types/
│   │           └── patient.ts
│   │
│   ├── emr/
│   │   ├── features/
│   │   │   ├── chart-overview/
│   │   │   ├── vitals/
│   │   │   ├── medications/
│   │   │   ├── procedures/
│   │   │   ├── history/
│   │   │   ├── immunizations/
│   │   │   ├── documents/
│   │   │   └── access-log/
│   │   └── shared/
│   │       ├── chart-layout.tsx
│   │       └── types/emr.ts
│   │
│   ├── labs/
│   ├── staff/
│   ├── assets/
│   ├── workspace/
│   ├── notifications/
│   └── queue/
│
└── shared/
    ├── api/
    ├── components/
    ├── guards/
    ├── hooks/
    ├── providers/
    ├── types/
    └── widgets/
        └── app-shell/
</code></pre>
<h3>When to Use Sub-Features</h3>
<p>The decision to make <code>patients</code> a domain with sub-features rather than a single flat feature comes down to one question: <em>do these sub-capabilities have meaningfully independent lifecycles?</em></p>
<p>A patient list screen and a patient profile screen do not share state. A patient registration flow and a patient transfer flow have entirely different API hooks, different screen components, and different form logic. They happen to be about the same domain entity (the patient), but they're developed, tested, and modified independently.</p>
<p>If they lived in a flat <code>patients/</code> folder, you'd have a folder with 30+ files that are only loosely related. Sub-features give each capability its own clean boundary. <code>patient-register/</code> contains exactly what you need to understand the registration flow. <code>patient-transfers/</code> contains exactly what you need to understand the transfer flow. Neither reaches into the other.</p>
<p>The rule I follow: introduce a sub-feature level when a top-level feature would otherwise contain more than about 15–20 files, or when distinct capabilities within that feature need to be understood independently.</p>
<h3>Feature-Level <code>shared/</code></h3>
<p>Each top-level domain has its own <code>shared/</code> folder for code shared across its sub-features but not needed by other top-level features:</p>
<ul>
<li><p><code>emr/shared/chart-layout.tsx</code> — the layout wrapper used by every EMR sub-screen (vitals, medications, procedures all share the same chrome)</p>
</li>
<li><p><code>emr/shared/types/emr.ts</code> — the TypeScript types for EMR entities</p>
</li>
<li><p><code>patients/shared/types/patient.ts</code> — the patient type shared across all patient sub-features</p>
</li>
</ul>
<p>This is the same promotion rule applied one level down: code starts in the sub-feature, moves to the domain-level <code>shared/</code> when the second sub-feature needs it, moves to the top-level <code>shared/</code> when two top-level features need it.</p>
<h3>The App-Level <code>shared/</code></h3>
<p>Medcord's top-level <code>shared/</code> is more substantial than TrustRail's because the application is larger:</p>
<pre><code class="language-plaintext">shared/
├── api/
│   └── use-hospital-by-slug.ts   (used by auth + workspace + all features)
├── components/
│   └── entity-link.tsx           (universal cross-entity navigation)
├── guards/
│   ├── auth-guard.tsx
│   └── hospital-guard.tsx
├── hooks/
│   ├── use-auth.ts
│   ├── use-hospital-slug.ts
│   └── use-permissions.ts
├── providers/
│   ├── auth-provider.tsx
│   └── user-bootstrap.tsx
├── types/                        (shared domain types: Hospital, Patient, Staff...)
└── widgets/
    └── app-shell/                (sidebar, topbar, user menu — used by every feature)
</code></pre>
<p><code>use-permissions.ts</code> is in <code>shared/hooks/</code> because RBAC permission checking is used across 6 different features (staff management, patient registration, EMR write access, lab order creation, asset management, workspace settings). Moving it to <code>shared/</code> the first time two features needed it was the right call — it has never needed to move again.</p>
<hr />
<h2>WordShot: The Decision That Proved the Pattern</h2>
<p>WordShot's FSD structure was discussed in the previous series of posts on this blog, but it's worth revisiting through the lens of a specific decision: demo mode.</p>
<p>Demo mode is an interactive walkthrough for first-time users. It shows the game flow step-by-step without making any API calls, without joining a real game room, without touching the WebSocket. It's entirely self-contained.</p>
<p>Before FSD, building demo mode in the old folder-by-type structure would have required:</p>
<ul>
<li><p>Adding demo-specific state to the existing Redux store (or creating a parallel store)</p>
</li>
<li><p>Adding conditional rendering to game components to handle "am I in demo mode?" checks</p>
</li>
<li><p>Threading a <code>isDemoMode</code> prop through the component tree</p>
</li>
<li><p>Hoping that game logic changes don't break the demo flow</p>
</li>
</ul>
<p>With FSD, demo mode was <code>features/demo/</code>. It contained its own state machine, its own screen components, its own mock data, its own routing. It shared only the base UI components from <code>shared/ui/</code> that it visually needed. The <code>game</code> and <code>multiplayer</code> features were not touched.</p>
<p>The demo took two days to build because the structure made it easy. There was no untangling to do. No "wait, this component is used by both the real game and the demo, how do I make them diverge?" There was no entanglement in the first place.</p>
<p>When the decision to remove demo mode later came up, it was a folder deletion and three route changes. That's the proof.</p>
<hr />
<h2>The <code>shared/</code> Discipline</h2>
<p>The most common way FSD falls apart in practice is <code>shared/</code> becoming a dumping ground.</p>
<p>I've seen codebases that claim to use FSD where <code>shared/</code> contains:</p>
<ul>
<li><p>Feature-specific components that "might be reused someday"</p>
</li>
<li><p>Utility functions used by exactly one feature</p>
</li>
<li><p>A <code>misc/</code> folder</p>
</li>
<li><p>Business logic that belonged in a feature service</p>
</li>
</ul>
<p>When <code>shared/</code> is polluted, the cross-feature import rule becomes unenforceable because everything is already in <code>shared/</code>. Features stop having clear boundaries. The original problem returns in a different location.</p>
<p>The discipline is: <code>shared/</code> <strong>must earn every file it contains.</strong> A file enters <code>shared/</code> when a second consumer appears. Not when you think it might be reused. Not because it "looks generic." When the second consumer actually arrives and needs it.</p>
<p>In practice, I've found that healthy <code>shared/</code> layers contain:</p>
<ul>
<li><p>The API client instance (one, used everywhere)</p>
</li>
<li><p>Auth primitives (the session hook, the auth provider, route guards)</p>
</li>
<li><p>The app shell / layout chrome</p>
</li>
<li><p>Base TypeScript types for domain entities that genuinely cross feature boundaries</p>
</li>
<li><p>A small set of UI utilities (token-safe wrappers, error display primitives)</p>
</li>
</ul>
<p>Healthy <code>shared/</code> layers do not contain:</p>
<ul>
<li><p>Form components (forms are feature-specific in almost every real codebase)</p>
</li>
<li><p>Anything with "business logic" in the filename or in the code</p>
</li>
<li><p>More than about 30–40 files total in a mid-sized application</p>
</li>
</ul>
<hr />
<h2>Where FSD Bends</h2>
<p>Three real failure modes I've encountered:</p>
<p><strong>The cross-feature import temptation.</strong> You're building <code>applications</code> in TrustRail and you need the list of trust wallets to render a dropdown. <code>trust-wallets</code> has a <code>use-trust-wallets.ts</code> hook that fetches exactly the data you need. The temptation is to import it directly. Don't.</p>
<p>The correct move: expose the data through a shared type and a prop or context, not through a direct import. If <code>applications</code> needs to know which trust wallet an application belongs to, that relationship lives in the application data itself, not in a cross-feature import.</p>
<p>If two features genuinely need to share data access at a deep level, that's a signal that the feature boundary was drawn incorrectly. Merge them, or extract the shared data concern to <code>shared/</code>.</p>
<p><strong>The premature sub-feature.</strong> Adding sub-feature levels when a flat feature would work fine adds cognitive overhead without benefit. <code>auth/features/login/screen/login-screen.tsx</code> has one more path component than <code>auth/screen/login-screen.tsx</code>. If the auth feature has three flows (login, register, forgot password) and each is less than 10 files, a flat structure is cleaner.</p>
<p>The sub-feature level is worth introducing when the sub-capability count makes the flat structure unwieldy — Medcord's <code>patients/</code> with six sub-features is clearer with the sub-level than without it.</p>
<p><strong>The naming inconsistency.</strong> FSD's value compounds when naming is consistent. When half the features use <code>use-{resource}.ts</code> for hooks and the other half use <code>{resource}Hook.ts</code>, the predictability breaks. When some screens are <code>{feature}-screen.tsx</code> and others are <code>{Feature}Page.tsx</code>, grep becomes your primary navigation tool again.</p>
<p>Establish the convention once, at project start, in a brief README. Enforce it in code review. It takes a day to establish and saves weeks over the lifetime of the project.</p>
<hr />
<h2>When Not to Use FSD</h2>
<p>Feature-Sliced Design has overhead. For the right project, the overhead pays for itself quickly. For the wrong project, it's bureaucratic noise.</p>
<p><strong>Don't use FSD when:</strong> the project has fewer than 3–4 independent features. A landing page site with one interactive form. A prototype with one screen. A personal tool with one workflow. Flat folder structures are faster to navigate and faster to change for small projects.</p>
<p><strong>Do use FSD when:</strong> the project is expected to grow, different parts of it are owned or will be owned by different people, features need to be added and removed without cross-feature contamination, or you've already experienced the folder-by-type pain and want to not experience it again.</p>
<p>The most predictable sign that a project needs FSD: you've opened the wrong file three times in the last day because two features have a component with the same name in the same <code>components/</code> folder.</p>
<hr />
<h2>Evaluation</h2>
<p>Across TrustRail, Medcord, and WordShot, FSD produced several consistent properties:</p>
<p><strong>Feature isolation</strong> — Changes to one feature never broke another. In Medcord, shipping a major rework of the patient transfer flow required touching exactly five files, all inside <code>patients/features/patient-transfers/</code>. No other feature was touched.</p>
<p><strong>Predictable navigation</strong> — A developer new to TrustRail could find any file in the codebase by following the naming convention without ever searching. Feature → sub-folder → file. The structure teaches itself.</p>
<p><strong>Clean deletions</strong> — WordShot's demo mode removal was a folder deletion. In Medcord, the <code>queue/</code> feature (a work-in-progress) could be removed at any point without touching anything else. Feature-scoped code deletes cleanly.</p>
<p><strong>Proportional complexity</strong> — TrustRail's five-feature flat structure and Medcord's nine-domain two-level structure are both instances of the same pattern, at different scales. The pattern doesn't impose Medcord's complexity on TrustRail or TrustRail's simplicity on Medcord.</p>
<hr />
<h2>The Honest Conclusion</h2>
<p>Feature-Sliced Design is not a silver bullet. It won't fix bad abstractions, poor naming, or unclear feature boundaries. What it does is give you a consistent, enforceable rule for where code lives — one that scales with project growth instead of collapsing under it.</p>
<p>The folder-by-type structure tells you what your code is. FSD tells you what it does and who owns it. At small scale, the distinction doesn't matter much. At medium and large scale, it's the difference between a codebase you can navigate and one you can only survive.</p>
<p><strong>The structure is not the architecture. But the right structure makes the architecture legible.</strong></p>
]]></content:encoded></item><item><title><![CDATA[Ìrísí - A React Library for Building Product Videos in JSX]]></title><description><![CDATA[A few months ago, I needed to create a product demo video for something I was building. Nothing fancy, just a clean walkthrough showing the feature, how it works, what it looks like in action.
I did w]]></description><link>https://crackedchefs.devferanmi.xyz/r-s-a-react-library-for-building-product-videos-in-jsx</link><guid isPermaLink="true">https://crackedchefs.devferanmi.xyz/r-s-a-react-library-for-building-product-videos-in-jsx</guid><category><![CDATA[React]]></category><dc:creator><![CDATA[Oluwaferanmi Adeniji]]></dc:creator><pubDate>Tue, 10 Mar 2026 08:42:21 GMT</pubDate><content:encoded><![CDATA[<p>A few months ago, I needed to create a product demo video for something I was building. Nothing fancy, just a clean walkthrough showing the feature, how it works, what it looks like in action.</p>
<p>I did what most engineers do. Opened a screen recorder, ran through the flow, hoped the demo gods were watching. They weren't. Three takes, and one accidental tab switch later, I had a funny recording and I was too tired to redo.</p>
<p>So I tried the other route, I sat with an LLM, described what I wanted scene by scene, asked it to help me piece together some animation code. It worked, kind of. But it was painful. The model kept hallucinating component APIs that didn't exist, the code it generated needed heavy editing, and the mental overhead of managing keyframes, timing offsets, and animation libraries manually was exhausting. I spent more time debugging the video than building the feature it was supposed to show.</p>
<p>That experience planted a question I couldn't let go of: <em>what if LLMs could generate product videos the same way they generate UI components?</em></p>
<p>The answer is <strong>Ìrísí</strong>.</p>
<hr />
<h2>The Name</h2>
<p>Ìrísí (ee-REE-see) is a Yoruba word. It means <em>appearance</em>, the way a thing looks, the form it takes, how it presents itself to the world.</p>
<p>I've been naming my developer tools after Yoruba words with real meaning. My AI SDK is called Ajala, after the legendary traveler who cycled the entire world. The name carries weight. Ìrísí felt right for this because that's exactly what the library is about: controlling how things appear, shaping the form of your product's story.</p>
<hr />
<h2>The Vision</h2>
<p>There's a version of the future I keep thinking about.</p>
<p>You finish building a feature. You write a short description of what it does. You hand it to an LLM. Two minutes later, you have a polished product video, animated, cinematic, the kind that makes your PM think you have a motion design team. You didn't touch a timeline. You didn't screen-record anything. The AI just... made it.</p>
<p>That's what Ìrísí is designed to enable.</p>
<p>The reason this is possible at all is because JSX is just text. It has a clear grammar, semantic component names, and readable props. When you write <code>&lt;Button variant="primary"&gt;Submit&lt;/Button&gt;</code>, a model doesn't need to guess what that does.</p>
<p>The problem with existing video tools is they weren't built with this in mind. They're built for humans operating GUIs — timeline panels, keyframe handles, layer stacks. That's a completely different paradigm from "write text that describes what you want."</p>
<p>Ìrísí is built on a different premise. What if a product video was just JSX?</p>
<pre><code class="language-jsx">&lt;Presentation theme="dark"&gt;
  &lt;Frame duration={5} transition="fade"&gt;
    &lt;BackgroundGradient colors={["#1e3a5f", "#0a0a0a"]} /&gt;
    &lt;Center&gt;
      &lt;Stack&gt;
        &lt;Eyebrow animate="fadeIn" delay={0.2}&gt;NEW FEATURE&lt;/Eyebrow&gt;
        &lt;Title animate="slideUp" delay={0.5} staggerBy="word"&gt;
          Instant Working Capital Loans
        &lt;/Title&gt;
        &lt;Subtitle animate="fadeIn" delay={1.2}&gt;
          Approved in under 60 seconds.
        &lt;/Subtitle&gt;
      &lt;/Stack&gt;
    &lt;/Center&gt;
  &lt;/Frame&gt;
&lt;/Presentation&gt;
</code></pre>
<p>Five seconds. Animated headline, staggered word reveal, gradient background, fade transition. An LLM can write that. It's just props.</p>
<hr />
<h2>The LLM-First Design Decision</h2>
<p>When I was speccing out Ìrísí, I made one decision that shapes everything: the API has to be something a model can generate reliably <em>without needing the docs open</em>.</p>
<p>That means three things:</p>
<p><strong>Component names read as plain English.</strong> <code>&lt;ScrambleText&gt;</code> scrambles text. <code>&lt;MaskReveal&gt;</code> reveals behind a mask. <code>&lt;BackgroundAurora&gt;</code> is an aurora background. No abbreviations, no cleverness.</p>
<p><strong>Prop values are named before they're numeric.</strong> <code>animate="slideUp"</code> not <code>animation={2}</code>. <code>easing="bounce"</code> not <code>easing={[0.68,-0.55,0.27,1.55]}</code>. The model should be able to infer what a prop does from its name alone.</p>
<p><strong>Zero required props where possible.</strong> A <code>&lt;Frame&gt;</code> with nothing on it still renders. A <code>&lt;Title&gt;</code> with just children looks good out of the box. The 80% case should be one line of JSX.</p>
<p>The result: when you hand Ìrísí's component descriptions to Claude and say "make me a product video for this feature", the code it writes should run. Not "pretty close, needs tweaks." Runs.</p>
<hr />
<h2>The Part That Makes It Real</h2>
<p>The component I'm most excited about is <code>&lt;UICursor&gt;</code>.</p>
<p>The hardest thing to fake in a product demo is interaction. You want to show a user clicking a button, filling a form, navigating a flow. Today your options are: screen-record the real thing and hope it cooperates, or spend an afternoon in a design tool animating every click manually.</p>
<p><code>&lt;UICursor&gt;</code> lets you script it:</p>
<pre><code class="language-jsx">&lt;UICursor
  moves={[
    { to: { x: 0.3, y: 0.4 }, duration: 0.8 },
    { to: { x: 0.6, y: 0.5 }, duration: 0.6, click: true },
    { type: "Feranmi Adeniji", duration: 1.5 },
    { to: { x: 0.7, y: 0.7 }, duration: 0.4, click: true }
  ]}
/&gt;
</code></pre>
<p>The cursor moves, clicks, types, choreographed, deterministic, zero screen recording. Pair it with <code>&lt;UIInput&gt;</code>, <code>&lt;UIButton&gt;</code>, <code>&lt;UIModal&gt;</code>, and you can build a complete product walkthrough for a feature that doesn't even exist yet.</p>
<p>An LLM can generate this. Describe the user flow in English, it writes the cursor script, the video renders.</p>
<hr />
<h2>How I'm Building It</h2>
<p>The spec is done, 170+ components across 17 categories, all documented with props. Text, layout, media, charts, UI mockups, backgrounds, transitions, annotations, audio, scroll controls, the works.</p>
<p>Implementation starts now. And I'm going to lean heavily on Claude Code for most of the actual coding.</p>
<p>There's a certain irony in using an LLM to build a library designed for LLMs to use. I'm fine with that.</p>
<hr />
<h2>Follow Along</h2>
<p>The repo is live at <a href="https://github.com/spiderocious/irisi">https://github.com/spiderocious/irisi</a>, star it to stay updated, open issues for features you want to see, or reach out if you want to contribute.</p>
<p>I'll be writing about the interesting engineering problems as they come up. The timeline engine is going to be non-trivial.</p>
<p>If you've ever finished a feature and wished the demo just built itself, that's what this is for.</p>
<p>Ìrísí. The way your product appears to the world.</p>
]]></content:encoded></item><item><title><![CDATA[AI Underwriting Without Credit Bureaus]]></title><description><![CDATA[Most of the world's credit infrastructure is built on an assumption: the borrower has a credit file. A bureau has their repayment history. A score exists. A lender queries it.
In Nigeria — and across ]]></description><link>https://crackedchefs.devferanmi.xyz/ai-underwriting-without-credit-bureaus</link><guid isPermaLink="true">https://crackedchefs.devferanmi.xyz/ai-underwriting-without-credit-bureaus</guid><dc:creator><![CDATA[Oluwaferanmi Adeniji]]></dc:creator><pubDate>Thu, 12 Feb 2026 09:30:00 GMT</pubDate><content:encoded><![CDATA[<p>Most of the world's credit infrastructure is built on an assumption: the borrower has a credit file. A bureau has their repayment history. A score exists. A lender queries it.</p>
<p>In Nigeria — and across most of sub-Saharan Africa — that assumption breaks for a significant portion of small and medium businesses. They transact in cash or on mobile money. They haven't borrowed from a formal institution before. They're not in any bureau. Their creditworthiness is real, but it's invisible to the standard pipeline.</p>
<p>TrustRail is a BNPL underwriting platform I built for this context. A merchant offers "buy now, pay in instalments" to their business customers. The customer submits a bank statement. TrustRail reads it, produces a trust score, and makes an approval decision. No bureau required.</p>
<p>This article is about the architecture of that pipeline — the data model, the analysis engine, the two-tier LLM integration, and the specific design decisions driven by the Nigerian financial context.</p>
<p>Let's dig in.</p>
<hr />
<h2>The Input Signal</h2>
<p>In the absence of a credit bureau, the bank statement is the richest available signal. A 3–6 month statement from a Nigerian bank (GTBank, Access, Zenith, Kuda, OPay, Moniepoint) tells you:</p>
<ul>
<li><p><strong>Income patterns</strong> — frequency, amounts, source classifications (salary, business receipts, transfers)</p>
</li>
<li><p><strong>Spending behaviour</strong> — recurring obligations, loan repayments, utility payments, gambling activity</p>
</li>
<li><p><strong>Balance health</strong> — average balance, minimum balance, whether the account goes negative</p>
</li>
<li><p><strong>Behavioural signals</strong> — bounced transactions, failed direct debits, overdraft usage</p>
</li>
<li><p><strong>Debt exposure</strong> — identifiable loan repayments to lenders like Carbon, FairMoney, PalmCredit, Renmoney</p>
</li>
</ul>
<p>None of these are as clean as a bureau-sourced credit score. But combined, they support a decision with meaningful predictive validity for the specific question being asked: "Can this business afford ₦X per month for the next N months?"</p>
<p>The challenge is that bank statements arrive as PDFs or CSVs in wildly different formats. GTBank's statement looks nothing like Kuda's. OPay's narrations are truncated differently from Access Bank's. A traditional rule-based parser that handles one bank's format doesn't handle another's without significant engineering for each.</p>
<p>This is where GPT-4o earns its place.</p>
<hr />
<h2>The Two-Engine Architecture</h2>
<p>TrustRail has two analysis paths for a submitted statement, and the choice between them depends on what the applicant uploaded.</p>
<h3>Primary Path: GPT-4o</h3>
<p>When the application includes an OpenAI file ID (set during upload via the <code>openaiService.uploadFileToOpenAI</code> call), the background job uses GPT-4o to read the PDF directly:</p>
<pre><code class="language-typescript">if (application.openai?.fileId) {
  const { analysisResult, fullResponse, fullPrompt } = await analyzeFileWithOpenAI(
    application.openai.fileId,
    application.installmentAmount,
    trustWallet.approvalWorkflow,
  );
  // ...
}
</code></pre>
<p>The model receives the PDF, the installment amount being applied for, and the approval thresholds configured by the merchant. It returns a structured <code>TrustEngineAnalysisResult</code> — but crucially, as discussed in the previous articles, it does not compute the trust score. The score is computed by the deterministic TypeScript engine after the model's extraction is complete.</p>
<h3>Fallback Path: TypeScript CSV Engine</h3>
<p>If the GPT-4o call fails, or if the application was submitted with a CSV buffer instead of a PDF file ID, the system falls back to a pure TypeScript CSV parser:</p>
<pre><code class="language-typescript">if (application.bankStatementCsvData) {
  trustEngineOutput = await analyzeApplication(application.applicationId);
}
</code></pre>
<p>The CSV engine uses keyword-based transaction classification against a catalogue of known Nigerian financial institution narrations. It handles standard CSV exports from the major banks, though it lacks the format flexibility of the LLM path.</p>
<p>The fallback is intentional architecture, not an error recovery afterthought. CSV analysis costs \(0 per application. PDF analysis via GPT-4o costs ~\)0.06–0.08 per application. For merchants with high application volumes and low average approval amounts, the CSV path may have better unit economics than the LLM path even when both are available.</p>
<hr />
<h2>The Classification Vocabulary</h2>
<p>The analysis engine classifies every transaction against a domain-specific taxonomy built for the Nigerian context. This is not a generic financial classifier — it's tuned to the specific institutions, product names, and transaction narration patterns that appear on Nigerian bank statements.</p>
<h3>Income Sources</h3>
<pre><code class="language-typescript">const CATEGORY_KEYWORDS = {
  salary: ['SALARY', 'SAL', 'WAGES', 'PAYROLL'],
  freelance: ['TRANSFER', 'REMITTANCE', 'UPWORK', 'FIVERR'],
  business: ['POS', 'PAYMENT FOR', 'SALES'],
};
</code></pre>
<p>The <code>freelance</code> category deliberately includes <code>TRANSFER</code> and <code>REMITTANCE</code> — in Nigeria, the vast majority of business-to-business and client-to-freelancer payments arrive as bank transfers with narrations that include these words. An overly conservative classifier that marks all transfers as non-income will systematically undercount income for self-employed applicants and freelancers.</p>
<p>This is the same classification problem TaxLens encounters with the Kuda format. The solution in TrustRail's CSV engine is the same: bias toward counting credits as income, reserve <code>transfer</code> classification only for credits you're confident are not income (reversals, refunds, self-transfers).</p>
<h3>Spending Categories</h3>
<pre><code class="language-typescript">const CATEGORY_KEYWORDS = {
  bills: ['PHCN', 'EKEDC', 'IKEDC', 'DSTV', 'GOTV', 'STARTIMES', 'AIRTEL', 'MTN', 'GLO', '9MOBILE'],
  loans: ['LOAN', 'REPAYMENT', 'INSTALLMENT', 'CARBON', 'BRANCH', 'FAIRMONEY', 'PALMCREDIT', 'RENMONEY'],
  gambling: ['BET', 'BETKING', 'SPORTYBET', 'NAIRABET', '1XBET', 'BET9JA', 'MSPORT', 'MERRYBET'],
};
</code></pre>
<p>The lender names in the <code>loans</code> category (Carbon, FairMoney, PalmCredit, Renmoney, Branch) are specifically Nigerian digital lenders. An applicant who is already repaying three of these simultaneously has a materially different risk profile from one with no active loans — even if both have the same average monthly income.</p>
<p>The gambling keywords are Nigerian-specific bookmakers. Frequent high-value transactions to betting platforms are a risk flag. They don't automatically trigger a decline — one bet per month isn't a signal — but above a threshold (₦10,000+ in gambling spend), a risk flag is raised.</p>
<h3>Bounce Detection</h3>
<pre><code class="language-typescript">const BOUNCE_KEYWORDS = [
  'INSUFFICIENT FUNDS',
  'REVERSAL',
  'DECLINED',
  'FAILED',
  'REJECTED',
];
</code></pre>
<p>Bounced transactions are identified by narration keyword matching. Bounce count feeds directly into the trust score (zero bounces: +5 points; 1–2 bounces: +2 points; 3+ bounces: -5 points). More than three bounces in a 3-month window triggers a <code>FREQUENT_BOUNCES</code> risk flag at HIGH severity.</p>
<hr />
<h2>The Trust Score Formula</h2>
<p>The trust score is a weighted sum across five independent dimensions. Each dimension is computed from structured transaction data. The score ranges from 0 to 100.</p>
<pre><code class="language-plaintext">Score = Income Stability (30) + Spending Behaviour (25) + Balance Health (20)
      + Transaction Behaviour (15) + Affordability (10)
</code></pre>
<h3>Income Stability (30 points max)</h3>
<p>Two sub-components:</p>
<p><strong>Income consistency</strong> (0–15): <code>incomeConsistency × 15</code>. The consistency metric is the ratio of months with meaningful credit activity to total months analyzed. An account with regular monthly credits close to the expected salary date scores near 1.0. An account with sporadic, irregular credits scores lower.</p>
<p><strong>Income-to-installment ratio</strong> (0–15):</p>
<ul>
<li><p>Ratio &lt; 0.2: +15 (installment is less than 20% of monthly income)</p>
</li>
<li><p>Ratio &lt; 0.3: +10</p>
</li>
<li><p>Ratio &lt; 0.4: +5</p>
</li>
<li><p>Ratio ≥ 0.4: +0</p>
</li>
</ul>
<p>This ratio directly tests whether the requested credit is proportionate to the applicant's income. A ₦50,000/month installment for someone earning ₦500,000/month is a very different risk from the same installment for someone earning ₦100,000/month, even if the absolute income is "adequate."</p>
<h3>Spending Behaviour (25 points max)</h3>
<p><strong>Debt ratio</strong> (0–10): <code>max(0, 10 - debtRatio × 20)</code>. Existing loan repayments as a fraction of monthly income, penalised at 20× the ratio. An applicant already paying back 40% of their income in loan repayments scores 0 on this sub-component.</p>
<p><strong>Gambling penalty</strong> (variable): If gambling spend is detected, the score is reduced by <code>min(10, gamblingSpend / 1000)</code>. Up to 10 points can be lost here. The divisor (1,000) is denominated in naira — ₦10,000 in gambling spend removes the full 10 points.</p>
<p><strong>Savings rate</strong> (0–15): <code>min(15, savingsRate × 20)</code>. <code>savingsRate = (avgMonthlyIncome - avgMonthlySpending) / avgMonthlyIncome</code>. An applicant who saves 75% of their income scores 15. An applicant who spends everything they earn scores 0.</p>
<h3>Balance Health (20 points max)</h3>
<p><strong>Average balance vs. installment</strong> (0–10):</p>
<ul>
<li><p>Average balance &gt; 2× installment: +10</p>
</li>
<li><p>Average balance &gt; 1× installment: +5</p>
</li>
<li><p>Average balance ≤ installment: +0</p>
</li>
</ul>
<p><strong>Minimum balance vs. installment</strong> (0–10):</p>
<ul>
<li><p>Minimum balance &gt; installment: +10</p>
</li>
<li><p>Minimum balance &gt; 0.5× installment: +5</p>
</li>
<li><p>Minimum balance ≤ 0.5× installment: +0</p>
</li>
</ul>
<p>The minimum balance test is particularly revealing. An account that has a good average balance but frequently drops near zero has a cash flow pattern inconsistent with reliable monthly payments. The minimum balance score penalises this specifically.</p>
<h3>Transaction Behaviour (15 points max)</h3>
<p><strong>Bounce count</strong> (+5/+2/-5): Described above.</p>
<p><strong>Overdraft usage</strong> (+5/-5): An account that has gone negative receives -5 points. An account that has never gone negative receives +5. This tests the account's buffer — not just its average behaviour.</p>
<p><strong>Transaction volume</strong> (+5/+2/+0): More than 30 transactions: +5. More than 15: +2. Fewer than 15: +0. A very low transaction count is a signal that the account may not be the applicant's primary account — they may be submitting a secondary account with selected favourable transactions.</p>
<h3>Affordability (10 points max)</h3>
<pre><code class="language-plaintext">affordabilityRatio = installmentAmount / disposableIncome
disposableIncome = avgMonthlyIncome - (avgMonthlySpending + existingLoanRepayments)
</code></pre>
<ul>
<li><p>Ratio &lt; 0.2: +10</p>
</li>
<li><p>Ratio &lt; 0.3: +7</p>
</li>
<li><p>Ratio &lt; 0.4: +4</p>
</li>
<li><p>Ratio ≥ 0.5: <code>canAffordInstallment = false</code> → automatic DECLINED</p>
</li>
</ul>
<p>The <code>canAffordInstallment</code> flag is a hard gate. If the installment exceeds 50% of disposable income, the application is declined regardless of the trust score. This rule is evaluated before the score is used for the decision.</p>
<hr />
<h2>The Decision Gate</h2>
<p>The merchant configures three thresholds per TrustWallet (a TrustWallet is a product offering — the merchant may have multiple with different terms):</p>
<ul>
<li><p><code>autoApproveThreshold</code>: scores at or above this → APPROVED</p>
</li>
<li><p><code>autoDeclineThreshold</code>: scores below this → DECLINED</p>
</li>
<li><p><code>minTrustScore</code>: the floor — any score below this declines regardless of the other thresholds</p>
</li>
</ul>
<p>The decision function:</p>
<pre><code class="language-typescript">const makeDecision = (
  trustScore: number,
  approvalWorkflow: IApprovalWorkflow,
  affordabilityAssessment: AffordabilityAssessment,
): 'APPROVED' | 'FLAGGED_FOR_REVIEW' | 'DECLINED' =&gt; {
  if (!affordabilityAssessment.canAffordInstallment) return 'DECLINED';
  if (trustScore &lt; approvalWorkflow.minTrustScore) return 'DECLINED';
  if (trustScore &lt; approvalWorkflow.autoDeclineThreshold) return 'DECLINED';
  if (trustScore &gt;= approvalWorkflow.autoApproveThreshold) return 'APPROVED';
  return 'FLAGGED_FOR_REVIEW';
};
</code></pre>
<p>Scores between <code>autoDeclineThreshold</code> and <code>autoApproveThreshold</code> are <code>FLAGGED_FOR_REVIEW</code> — they're routed to a human reviewer on the merchant's side. This band gives the merchant control over their risk tolerance without requiring them to binary-classify every application. A conservative merchant might auto-approve only at 80+ and manually review everything from 55–79. An aggressive merchant might auto-approve at 65+ and only manually review 45–64.</p>
<p>This is the right model for a system replacing a credit bureau: the algorithm provides a signal, not an edict. The merchant keeps the approval authority and calibrates the thresholds based on their actual loss rates over time.</p>
<hr />
<h2>Risk Flags</h2>
<p>Beyond the score, the engine produces a set of named risk flags that the merchant can use to inform their manual review decisions:</p>
<table>
<thead>
<tr>
<th>Flag</th>
<th>Severity</th>
<th>Condition</th>
</tr>
</thead>
<tbody><tr>
<td><code>HIGH_GAMBLING_ACTIVITY</code></td>
<td>HIGH</td>
<td>Gambling spend &gt; ₦10,000</td>
</tr>
<tr>
<td><code>FREQUENT_BOUNCES</code></td>
<td>HIGH</td>
<td>Bounce count &gt; 3</td>
</tr>
<tr>
<td><code>OVERDRAFT_USAGE</code></td>
<td>MEDIUM</td>
<td>Account went negative</td>
</tr>
<tr>
<td><code>HIGH_DEBT_TO_INCOME</code></td>
<td>HIGH</td>
<td>Debt-to-income ratio &gt; 40%</td>
</tr>
<tr>
<td><code>CANNOT_AFFORD_INSTALLMENT</code></td>
<td>HIGH</td>
<td>Affordability ratio ≥ 50%</td>
</tr>
<tr>
<td><code>INVALID_STATEMENT</code></td>
<td>HIGH</td>
<td>Document validity check failed</td>
</tr>
</tbody></table>
<p>A <code>FLAGGED_FOR_REVIEW</code> decision with <code>HIGH_DEBT_TO_INCOME</code> and <code>FREQUENT_BOUNCES</code> should inform a human reviewer differently than the same decision with only <code>OVERDRAFT_USAGE</code>. The flags add texture to the decision that the single-number score cannot.</p>
<hr />
<h2>The Approved Path: Mandate Creation</h2>
<p>When an application is approved, TrustRail doesn't just send a webhook and stop. It creates a direct debit mandate via PayWithAccount/NIBSS so that installment collections can be automated without the customer having to manually make each payment.</p>
<p>The mandate creation runs immediately after the APPROVED decision:</p>
<pre><code class="language-typescript">if (trustEngineOutput.decision === 'APPROVED') {
  application.status = 'APPROVED';
  await application.save();

  const mandateResult = await createMandate(
    {
      accountNumber: application.customerDetails.accountNumber,
      bankCode: application.customerDetails.bankCode,
      bvn: application.customerDetails.bvn,
      // ...
    },
    business.billerCode,
    application.totalAmount,
  );

  application.pwaMandateRef = mandateResult.mandateRef;
  application.status = 'MANDATE_CREATED';
  await application.save();
}
</code></pre>
<p>The mandate reference is then available for the merchant to initiate collection on each instalment due date. The underwriting pipeline produces not just a decision but a collection infrastructure reference.</p>
<p>BVN data is encrypted at rest using an AES-based scheme before storage. The encryption key is environment-specific. BVN is transmitted to the mandate provider but never stored in plaintext in the application document.</p>
<hr />
<h2>Trade-offs and Honest Limitations</h2>
<p><strong>The classification keywords are a maintenance surface.</strong> The list of known lender names, utility providers, and gambling operators needs updating as the market changes. A new digital lender that isn't in the <code>loans</code> keyword list won't have its repayments counted against the debt ratio. This is a known gap. The LLM path mitigates it for PDF analysis — GPT-4o can identify "CARBON FINANCE REPAYMENT" as a loan repayment even without the keyword list — but the CSV fallback is keyword-dependent.</p>
<p><strong>Transaction volume is a weak proxy for account completeness.</strong> The +5 points for 30+ transactions per period is designed to penalise accounts with suspiciously few transactions. But a high-income individual who uses their corporate card for most expenses and only transfers their salary through the statement account might legitimately have 12 transactions in a month. The signal is directional, not definitive.</p>
<p><strong>The income consistency metric is simplified.</strong> The current implementation approximates consistency as <code>creditTransactions.length / (5 × monthsAnalyzed)</code>, assuming roughly 5 credits per month as a baseline. This works adequately for salary earners with regular deposits, but understates consistency for business owners who receive many smaller payments spread across the month. A more precise implementation would group transactions by calendar month and measure the variance in monthly credit totals — a project for v2.</p>
<p><strong>The trust score is not calibrated against outcomes.</strong> The weightings (30/25/20/15/10) and sub-component thresholds are based on financial domain knowledge, not trained against a labelled dataset of historical applications and their actual repayment outcomes. As TrustRail accumulates repayment data, those weightings should be revisited against actual loss rates per score band. The current score is a principled starting point, not a validated predictor.</p>
<p><strong>BVN encryption is application-layer, not field-level.</strong> The BVN is encrypted before the application document is created, but it's stored as a single encrypted string in the document. A database-level field encryption with key rotation would be more robust — this is the kind of compliance detail that matters more as transaction volume grows.</p>
<hr />
<h2>Evaluation</h2>
<p>The architecture produces several measurable properties relevant to the use case:</p>
<p><strong>Format flexibility</strong> — The LLM path handles any Nigerian bank's PDF format without parser engineering for each institution. New bank statement formats are handled automatically, within the classification guidance in the system prompt.</p>
<p><strong>Explainability</strong> — Every component of the trust score maps to a named sub-component with a formula. A declined applicant can be shown exactly why: "Your trust score is 44. Your income-to-installment ratio cost you 0/15 on income stability. Your debt-to-income ratio is 47% — above the 40% threshold — which triggered a HIGH_DEBT_TO_INCOME flag." This is not achievable with a black-box model score.</p>
<p><strong>Merchant control</strong> — The three-threshold configuration (auto-approve, auto-decline, minimum score) gives merchants genuine control over their risk appetite without requiring them to understand the underlying formula. They calibrate based on their observed approval rates and loss rates, not on score internals.</p>
<p><strong>Zero-bureau operation</strong> — The entire pipeline runs without querying any credit bureau, credit registry, or external data provider beyond the bank statement itself and the mandate creation API. This is the deliberate design choice that makes the system useful for the specific population of applicants who are invisible to conventional underwriting.</p>
<hr />
<h2>The Honest Conclusion</h2>
<p>Bank statement underwriting is not a replacement for bureau-based credit scoring at scale. Bureaus aggregate repayment history across thousands of lenders and years of data. A 3-month bank statement is a much noisier signal.</p>
<p>What it is, is a <em>viable</em> signal for a specific use case: a merchant offering BNPL to their existing customers, where the merchant has prior relationship context and the amounts involved are moderate. The trust score doesn't need to be as precise as a FICO score — it needs to be more informative than a gut feeling, and it needs to reach the population that the bureau pipeline cannot.</p>
<p>For that use case, in that market, with those constraints, the architecture described here does the job.</p>
<p><strong>The credit bureau is not the only path to a lending decision. It's just the one the infrastructure was built around.</strong></p>
]]></content:encoded></item><item><title><![CDATA[How I Use AI to Code Effectively, Part 2]]></title><description><![CDATA[Hi and welcome, this is a continuation of the Part 1 of my AI coding series, if you're new here, please read part one here first.
My testing stack looks normal at the bottom: Vitest for units, Jest wh]]></description><link>https://crackedchefs.devferanmi.xyz/how-i-use-ai-to-code-effectively-part-2</link><guid isPermaLink="true">https://crackedchefs.devferanmi.xyz/how-i-use-ai-to-code-effectively-part-2</guid><dc:creator><![CDATA[Oluwaferanmi Adeniji]]></dc:creator><pubDate>Thu, 12 Feb 2026 09:30:00 GMT</pubDate><content:encoded><![CDATA[<p>Hi and welcome, this is a continuation of the Part 1 of my AI coding series, if you're new here, please read part one <a href="https://crackedchefs.devferanmi.xyz/how-i-use-ai-to-code-effectively-part-1">here</a> first.</p>
<p>My testing stack looks normal at the bottom: Vitest for units, Jest where the team's already standardised on it, Cypress and Playwright for end-to-end, Testcontainers for integration against real Postgres, k6 for load. I click through the UI myself when something feels off. I write tests like everyone else.</p>
<p>What's different is what sits on top of all of that, a layer of AI QA agents that act like a small swarm of senior QA engineers. One drives a real Chromium browser via a CLI called agent-browser. One hits live APIs and the database directly. One runs my existing regression suites against the latest build, marks which tests need re-running after a fix, and writes new test scripts that get committed back into the suite. Another reviews the design system implementations against the source HTML. The Demo Director persona produces launch films from real product DOM.</p>
<p>The agents don't replace the testing infrastructure. They sit above it and exercise it intelligently, generating edge cases I wouldn't have thought of, load-testing patterns I wouldn't have bothered scripting, source audits I wouldn't have had time to run by hand.</p>
<p>None of the infrastructure underneath is novel. The interesting part is the layer of discipline that makes the AI agents above it produce QA passes I actually trust.</p>
<p>This is Part 2 of a <a href="https://crackedchefs.devferanmi.xyz/series/ai-llm-coding">series</a> on how I use AI to code effectively. <a href="https://crackedchefs.devferanmi.xyz/how-i-use-ai-to-code-effectively-part-1">Part 1</a> covered spec-driven development, the persona/skill/codebase model, context management with Opus and Sonnet, and code review at scale. This part covers the agents that go beyond writing code, testing, design, orchestration, and the things I've built when no existing tool fit.</p>
<p>Everything I'll reference is in the open-source repo at github.com/spiderocious/agentic-workflow. Open it in a tab and follow along.</p>
<p>Let's get cracking.</p>
<p>Why Not Just Playwright? The obvious question: I have Playwright. I have Vitest. I have Cypress for some flows. Why bother adding an AI QA agent on top?</p>
<p>The short answer: because the framework runs the cases I already thought to write. The agent generates the cases I didn't.</p>
<p>A Playwright suite knows exactly what I told it. If I wrote 40 tests for the checkout flow, it runs 40 tests. It doesn't ask "what about the case where the user opens two tabs and submits both?" It doesn't notice that the empty state has no aria-label. It doesn't try the "rapid-fire click the submit button twice" race condition I forgot to spec. It doesn't decide to throw 50 concurrent requests at the idempotency endpoint just to see what happens.</p>
<p>The AI QA agent does all of that. It thinks like a senior QA engineer thinks, what could break this?, and then it goes and tries. It generates edge cases as a function of the feature shape, not as a function of the test list I happened to write three months ago.</p>
<p><strong>Five things the agent does that the framework alone doesn't:</strong></p>
<ol>
<li><p>Generates edge cases dynamically. Given a feature spec, the agent enumerates: happy path, empty state, boundary values, concurrent submissions, expired tokens, wrong roles, malformed input, network failures, race conditions, idempotency mismatches. The framework runs the cases the agent invents and the cases I previously wrote.</p>
</li>
<li><p>Runs my existing regression suites and triages. When the agent kicks off a QA pass, it runs the full Vitest + Playwright suite first, parses the output, classifies failures into "actually broken" / "flaky" / "blocked by something earlier," and writes a triage report. The framework is the execution engine; the agent is the engineer reading the output.</p>
</li>
<li><p>Writes new test scripts that get committed back. Edge cases the agent discovers become permanent. The agent generates a *.test.mjs or a Playwright spec that captures the failure, the fix lands, the test joins the regression suite. The agent feeds my pyramid.</p>
</li>
<li><p>Drives interactive load tests. "Hit this endpoint 50 times in parallel with different idempotency keys and tell me what the DB looks like at the end" is one prompt. Setting that up in k6 is a script I'd skip writing for a one-off check. The agent does it in 20 seconds.</p>
</li>
<li><p>Runs source audits in parallel with execution. Before the browser even opens, the agent greps the codebase for known anti-patterns (raw &amp;&amp; in JSX, missing onError on mutations, useEffect + fetch races) and files them as bugs alongside the runtime tests. The two streams of findings, static and dynamic, land in the same report.</p>
</li>
</ol>
<p>The relationship: the framework is the floor; the agent is the senior engineer running circles on top of it. Vitest doesn't get replaced. Playwright doesn't get replaced. They get used harder, and by something that knows what to look for beyond the cases I had time to write.</p>
<h2>The Web QA Agent — 8 Phases</h2>
<p>The web QA agent runs on <code>agent-browser</code>, a CLI tool that exposes a persistent Chromium daemon as bash commands. It complements Playwright rather than replacing it, Playwright owns the regression suite that runs on every commit; <code>agent-browser</code> is what the AI agent reaches for when it's exploring, generating edge cases on the fly, or driving the browser to reproduce a one-off scenario before deciding whether it deserves a permanent Playwright spec.</p>
<p>The persona at <a href="https://github.com/spiderocious/agentic-workflow/blob/main/personas/qa-frontend.md"><code>personas/qa-frontend.md</code></a> loads two skills:</p>
<ul>
<li><p><a href="https://github.com/spiderocious/agentic-workflow/blob/main/skills/agent-browser.md"><code>agent-browser.md</code></a>: the command reference (29KB)</p>
</li>
<li><p><a href="https://github.com/spiderocious/agentic-workflow/blob/main/skills/agent-browser-qa-guide.md"><code>agent-browser-qa-guide.md</code></a>: the field manual (38KB)</p>
</li>
</ul>
<p>The agent follows an 8-phase loop on every QA pass:</p>
<h3>Phase 1: Pre-flight</h3>
<p>Confirm backend is healthy, confirm frontend is up, seed test data via API (never via UI):</p>
<pre><code class="language-bash">curl http://localhost:3000/api/v1/health
curl http://localhost:5173 | head -3
TOKEN=$(curl -s -X POST http://localhost:3000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@app.test","password":"Pass123!"}' | jq -r '.data.tokens.accessToken')
</code></pre>
<p>If the backend isn't responding, the agent stops. It doesn't try to test against a dead server. It says "the backend is down" and waits.</p>
<h3>Phase 2: Source audit before opening the browser</h3>
<p>Grep the codebase for known anti-patterns before touching the UI. File findings as <code>CC-##</code> (cross-cutting) entries in the test plan with P1/P2/P3 severity:</p>
<pre><code class="language-bash"># Raw &amp;&amp; in JSX (must be &lt;Show when={...}&gt;)
grep -rn "{.*&amp;&amp;" src/features/ --include="*.tsx" | grep -v "//\|test"

# Missing onError on mutations (silent failure bug)
grep -rn "useMutation\|mutationFn" src/features/ --include="*.ts" -l \
  | xargs grep -L "onError" 2&gt;/dev/null
</code></pre>
<p>Every grep hit is a candidate bug. The agent files it before execution, then either confirms or refutes it in the browser.</p>
<h3>Phase 3: Write the test plan</h3>
<p>A markdown table written <strong>before execution</strong>. Columns: <code>ID | Test | Expected | How to verify</code>. This is the contract, what the agent will check, what passing means.</p>
<h3>Phase 4: Open the browser session</h3>
<pre><code class="language-bash">agent-browser close --all
agent-browser open http://localhost:5173
agent-browser snapshot -i
agent-browser fill @e4 "test@app.test"
agent-browser fill @e8 "Pass123!"
agent-browser click @e6
agent-browser wait --url "**/dashboard"
</code></pre>
<p>The <code>@e4</code>, <code>@e8</code>, <code>@e6</code> are accessibility-tree references from the snapshot. They reset on every snapshot, which is one of the agent's most common foot-guns (see the gotchas section).</p>
<h3>Phase 5: Execute per case</h3>
<p>For every test case: navigate → screenshot the initial state → action → wait for completion → screenshot the final state → record <code>PASS</code> / <code>FAIL</code> / <code>SKIP</code> / <code>BLOCKED</code>.</p>
<p>Critical discipline: <strong>never</strong> <code>sleep</code>. Always wait on an explicit signal:</p>
<pre><code class="language-bash">agent-browser wait --load networkidle
agent-browser wait --text "Dashboard"
agent-browser wait --url "**/h/**"
agent-browser wait "#spinner" --state hidden
</code></pre>
<p><code>sleep 2</code> is the QA agent equivalent of <code>try { ... } catch (e) { /* ignore */ }</code>. It hides flakiness instead of fixing it.</p>
<h3>Phase 6: Verify persistence after every mutation</h3>
<p>After any state change, reload and re-check. A "success toast" that fires but doesn't actually persist is a real bug, and it's the kind that source review can't catch.</p>
<pre><code class="language-bash">agent-browser click @e12   # Save button
agent-browser wait --load networkidle
agent-browser eval "document.body.innerText" | grep -E "success|saved"
agent-browser reload
agent-browser wait --load networkidle
agent-browser eval "document.body.innerText"   # Verify the change persisted
</code></pre>
<h3>Phase 7: Test all four React Query states</h3>
<p>Loading, success, error, empty. The agent tests all four for every data-fetching screen:</p>
<pre><code class="language-bash"># loading — screenshot immediately on open
agent-browser navigate http://localhost:5173/feature
agent-browser screenshot /path/loading.png

# success — wait for data
agent-browser wait --text "Expected Content"
agent-browser screenshot /path/success.png

# error — mock the API
agent-browser network route "*/api/v1/feature*" \
  --body '{"error":{"code":"internal","message":"Service unavailable"}}'
agent-browser reload
agent-browser screenshot /path/error.png
agent-browser network unroute

# empty — fresh account or filter to nothing
agent-browser screenshot /path/empty.png
</code></pre>
<p>This catches the bugs nobody tests: the loading state that flickers, the error state that crashes, the empty state that shows "0" because of the <code>&amp;&amp;</code> bug.</p>
<h3>Phase 8: Write the execution report</h3>
<p>The report comes <strong>after</strong> all tests complete. Never written mid-run. Format includes a Summary table, per-screen results, and new bugs found at the bottom.</p>
<h3>DOM Patterns That Don't Lie</h3>
<p>The agent has three patterns for interacting with the DOM that are non-obvious and worth showing.</p>
<h3>React controlled-input fill</h3>
<p><code>agent-browser type</code> doesn't work on React controlled inputs, typing into a <code>&lt;input&gt;</code> whose value comes from <code>useState</code> doesn't fire React's <code>onChange</code>. The workaround:</p>
<pre><code class="language-bash">agent-browser eval "
  const input = document.querySelector('input[name=email]');
  const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
  setter.call(input, 'new@value.com');
  input.dispatchEvent(new Event('input', {bubbles: true}));
"
</code></pre>
<p>This uses the property descriptor setter to bypass React's input proxy. Or just use <code>agent-browser fill @ref "text"</code> which handles this internally.</p>
<h3>Modal detection trick</h3>
<pre><code class="language-bash">agent-browser eval "document.body.childElementCount"
# Returns 2 = no modal
# Returns 3 = modal open

agent-browser eval "document.body.children[2].innerText"
agent-browser eval "document.body.children[2].querySelectorAll('button')[2].click()"
</code></pre>
<p>Most modal libraries portal into <code>document.body</code>. Counting <code>body</code> children is a cheap, reliable way to detect modal presence without selectors that change between renders.</p>
<h3>Verify the API was actually called</h3>
<p>The agent can record HAR (HTTP Archive) traces and inspect them:</p>
<pre><code class="language-bash">agent-browser network har start
agent-browser eval "document.querySelectorAll('button')[3].click()"
agent-browser wait 2000
agent-browser network requests --method POST --filter /result
# If empty: button did NOT call the result endpoint
# If present: it did
</code></pre>
<p>This is how the agent catches "save button fires the wrong mutation" bugs that look fine in source review.</p>
<hr />
<h2>Never Stop Investigating on the First Error</h2>
<p>The single most important QA agent anti-pattern, from the <a href="https://github.com/spiderocious/agentic-workflow/blob/main/skills/agent-browser-qa-guide.md"><code>agent-browser-qa-guide.md</code></a> skill:</p>
<blockquote>
<p>Toast didn't appear → check if the API call was made at all → API call not made → check if the button click fired → button click fired → check if the mutation was set up correctly → mutation wrong → check what endpoint it's calling.</p>
</blockquote>
<p>When something fails, dig one level deeper. The default "this test failed" output is useless. The "this test failed because the button click didn't fire the mutation because the mutation was bound to a different selector because the component re-rendered and lost its handler" output is actionable.</p>
<p>The agent's other hard rules (verbatim from the skill):</p>
<blockquote>
<p>Never report PASS if you didn't verify it. Never mark a test PASS based on source code alone. Never use sub-agents for testing: <code>agent-browser</code> is operated directly via Bash. Sub-agents cannot see your browser session.</p>
</blockquote>
<p>The browser is the source of truth. The agent's report is not.</p>
<hr />
<h2>Running the Existing Regression Suite, Triaging, and Feeding It Back</h2>
<p>The QA agent doesn't just run its own ad-hoc tests. The first thing it does on any non-trivial pass is execute the project's existing Playwright + Vitest + Cypress suites against the current build, parse the output, and write a triage report.</p>
<pre><code class="language-bash">pnpm test --run --reporter=json &gt; /tmp/vitest-results.json
pnpm playwright test --reporter=json &gt; /tmp/playwright-results.json
</code></pre>
<p>Then the agent reads the JSON output and classifies every failure:</p>
<ul>
<li><p><strong>Actually broken</strong>: assertion mismatched the implementation. File as a bug.</p>
</li>
<li><p><strong>Flaky</strong>: passed on retry, or a timing-related failure pattern. File as a flake and add to the "stabilise" backlog.</p>
</li>
<li><p><strong>Blocked by an earlier failure</strong>: a downstream test that depends on a fixture an earlier test was supposed to create. Mark BLOCKED, don't mark FAIL.</p>
</li>
<li><p><strong>Out of date</strong>: assertion is checking for behaviour that was intentionally changed. The test needs updating, not the code. Flag for human review.</p>
</li>
</ul>
<p>The triage report lands at the top of the QA pass output. By the time I read it, I already know which failures are real, which are noise, and which need a test update versus a code fix. This is the work that used to consume the first 30 minutes of every QA review.</p>
<h3>Writing test scripts that get committed back</h3>
<p>The most valuable thing the agent does, in my opinion: when it discovers an edge case during exploratory testing, it writes a permanent test for it.</p>
<p>The pattern: agent generates a "what about..." case → executes it against the live app → confirms it's a bug (or a missing test) → writes a <code>*.test.mjs</code> script (for API) or a Playwright spec (for UI) → adds it to the appropriate suite → opens a PR with the test.</p>
<p>Example: the agent is testing the bulk-reclassify endpoint. It tries 1, 10, 100, 1000 transactions. All pass. It tries 10,000. The endpoint times out at 30 seconds. The agent doesn't just report the bug: it writes:</p>
<pre><code class="language-js">// docs/qas/backend/scripts/bulk-reclassify-limits.test.mjs
await test('BR-LIM-01', 'Bulk reclassify of 10,000 transactions completes under 30s', async () =&gt; {
  const txns = await createTransactions(10_000);
  const start = Date.now();
  const res = await post('/statements/bulk-reclassify', { transactionIds: txns });
  const elapsed = Date.now() - start;
  assertStatus(res, 200);
  assert(elapsed &lt; 30_000, `took ${elapsed}ms, expected &lt; 30000ms`);
});
</code></pre>
<p>The script gets committed alongside the fix. The regression suite is now one edge case smarter. The next time someone touches the bulk-reclassify code, the suite catches the regression before it ships.</p>
<p>This is the loop that makes the testing tier compound. Each QA pass leaves the suite stronger than it found it. The agent isn't replacing the test framework, it's feeding it.</p>
<h3>Marking what must be re-run after a fix</h3>
<p>When a bug is fixed and the agent re-runs verification, it doesn't run the entire suite from scratch (slow). It identifies the minimum set of tests affected by the fix:</p>
<pre><code class="language-bash">git diff HEAD~1 --name-only | xargs -I {} pnpm test --related {}
pnpm playwright test --grep "@touched-by-fix"
</code></pre>
<p>Then it runs those, plus the test that originally reproduced the bug, plus any test the fix's diff touches. The agent writes the re-run list explicitly in the report, "I ran these 14 tests after the fix, here's why these and not the other 480." When the report lands, I can see exactly what was verified and what wasn't.</p>
<p>For high-risk fixes (security, financial, auth), the agent re-runs the full suite. For surgical fixes (typo in a string), it runs the targeted set. The classification is part of the bug entry itself.</p>
<hr />
<h2>The API QA Agent — Same Shape, Different Hands</h2>
<p>The API QA agent at <a href="https://github.com/spiderocious/agentic-workflow/blob/main/personas/qa-backend.md"><code>personas/qa-backend.md</code></a> follows the same 8-phase shape, but with different tools: <code>curl</code>, <code>jq</code>, <code>psql</code>, <code>mongosh</code>, <code>redis-cli</code>. No browser.</p>
<p>The first thing the agent does, before any test, is confirm the URL mount points:</p>
<pre><code class="language-bash">grep -n "app\.use" src/index.ts
</code></pre>
<p>This sounds trivial. It isn't. The single most common QA agent mistake is assuming admin routes mount at <code>/api/v1/admin</code> when they actually mount at <code>/admin</code>. The agent always confirms before testing.</p>
<h3>The per-feature reading order</h3>
<p>For every feature the agent tests, it reads the source in this order:</p>
<pre><code class="language-bash"># 1. Schema/validation — ground truth for field names and enums
cat src/features/hospitals/hospital.schema.ts
# 2. Service — what it returns
cat src/features/hospitals/hospital.service.ts
# 3. Repo — what DB fields are selected/excluded
cat src/features/hospitals/hospital.repo.ts
# 4. Controller — what response shape is built
cat src/features/hospitals/hospital.controller.ts
# 5. Routes — paths, methods, middleware
cat src/features/hospitals/hospital.routes.ts
</code></pre>
<p>This is the order that catches drift. The docs lie. The code is truth. The schema is the most reliable starting point because field names there are checked at runtime by Zod, not just at compile time.</p>
<h3>Plain Node fetch, no test framework</h3>
<p>The test script is plain Node ESM with <code>fetch</code>, no test framework. From <a href="https://github.com/spiderocious/agentic-workflow/blob/main/skills/backend-qa-agent.md"><code>backend-qa-agent.md</code></a>:</p>
<pre><code class="language-js">const BASE = 'http://localhost:8085/api/v1';

async function request(base, path, { method = 'GET', body, token } = {}) {
  const headers = { 'Content-Type': 'application/json' };
  if (token) headers['Authorization'] = `Bearer ${token}`;
  const res = await fetch(`\({base}\){path}`, {
    method, headers,
    body: body ? JSON.stringify(body) : undefined,
  });
  let data;
  try { data = await res.json(); } catch { data = null; }
  return { status: res.status, data };
}

let passed = 0, failed = 0, blocked = 0, skipped = 0;
const failures = [];

function pass(id, label)         { console.log(`  \({id}: \){label}`); passed++; }
function fail(id, label, reason) { console.log(`  \({id}: \){label}\n    -&gt; ${reason}`); failed++; failures.push({id,label,reason}); }
function block(id, label, reason){ console.log(`  \({id}: \){label} [BLOCKED: ${reason}]`); blocked++; }
function skip(id, label, reason) { console.log(`  \({id}: \){label} [SKIP: ${reason}]`); skipped++; }
</code></pre>
<p>The four-state framework: PASS / FAIL / SKIP / BLOCKED — is the same as the web QA agent. BLOCKED means "prerequisite broken." Never PASS.</p>
<p>A real test:</p>
<pre><code class="language-js">await test('A-HP-01', 'Register new user returns 201 with user+tokens', async () =&gt; {
  const res = await post('/auth/register', {
    email: 'testuser_a_hp_01@app.test',
    name: 'Test A-HP-01',
    password: 'Pass123!',
  });
  assertStatus(res, 201);
  const d = res.data.data;
  assert(d.user?.id, 'user.id present');
  assert(d.tokens?.accessToken, 'accessToken present');
  assert(d.tokens?.refreshToken, 'refreshToken present');
});

await test('A-EG-01', 'Register duplicate email returns 409', async () =&gt; {
  const res = await post('/auth/register', { /* existing email */ });
  assertStatus(res, 409);
  assertEqual(res.data.error?.code, 'conflict');
});
</code></pre>
<p>The rules for writing these tests (verbatim from the skill):</p>
<blockquote>
<p>Always use fresh tokens. Never hardcode a token. Login at bootstrap time. Always propagate IDs. Create a resource, capture its ID, use it in dependent tests. If creation fails, <code>block()</code> all dependents explicitly. Never swallow 204 body parsing. Quote the actual response in failures don't just say "got 400", say <code>got 400: {"error":{"code":"validation_error","message":"..."}}</code> Use <code>Date.now()</code> for unique slugs. Hardcoded unique values get 409 conflicts on the second run.</p>
</blockquote>
<p>The full how-to is in <a href="https://github.com/spiderocious/agentic-workflow/blob/main/docs/how-to-use-qa-agents.md"><code>docs/how-to-use-qa-agents.md</code></a>.</p>
<hr />
<h2>The Security Agent</h2>
<p>The security agent is the most senior of the QA personas. Its identity is "you are a security engineer auditing this codebase for the kinds of bugs that show up in postmortems six months from now." It loads a dedicated security skill (<a href="https://github.com/spiderocious/agentic-workflow/blob/main/skills/security-review.md"><code>skills/security-review.md</code></a>) plus the universal <code>hard-lessons.md</code>, and it runs against whatever surface I point it at: a feature branch, a specific file, a full module, the entire backend.</p>
<p>It works the way the other QA agents do: source audit first, then live execution, then a structured report. The differences are in what it audits, what it executes, and how it grades severity.</p>
<h3>What it audits (statically)</h3>
<p>A series of grep + reading passes against the source, looking for known security anti-patterns:</p>
<p><strong>Password and credential handling.</strong></p>
<ul>
<li><p>bcrypt usage flagged: the workspace default is Argon2id (<code>memoryCost: 64MB</code>, <code>timeCost: 3</code>, <code>parallelism: 1</code>, tuned to ~200ms per hash on production hardware).</p>
</li>
<li><p>Any plaintext password storage or logging.</p>
</li>
<li><p>Any password comparison that isn't constant-time.</p>
</li>
<li><p>API keys, JWT secrets, or webhook secrets stored anywhere except environment variables.</p>
</li>
</ul>
<p><strong>Token and session handling.</strong></p>
<ul>
<li><p>Access tokens longer than 15 minutes flagged for review.</p>
</li>
<li><p>Refresh tokens stored without server-side <code>sha256(token)</code> indirection.</p>
</li>
<li><p>Refresh token rotation missing, every <code>/auth/refresh</code> must invalidate the old token and issue a new one.</p>
</li>
<li><p>Refresh token reuse detection missing, a revoked refresh token presented again must revoke all sessions for that user.</p>
</li>
<li><p>Sensitive actions (change email/phone/password, delete account, withdraw above threshold) without a fresh OTP gate.</p>
</li>
</ul>
<p><strong>HTTP and authz surface.</strong></p>
<ul>
<li><p>Async route handlers without <code>asyncHandler</code> (unhandled rejection risk).</p>
</li>
<li><p>Routes missing auth middleware (compared against the project's public-route allowlist).</p>
</li>
<li><p>Routes missing role-check or ownership-check middleware.</p>
</li>
<li><p>Route registration order, specific paths must precede parameterized paths (catches the <code>/me</code> vs <code>/:userId</code> shadowing class of bug).</p>
</li>
<li><p>Any service that accepts <code>req</code> as a parameter (HTTP leaking into business logic; obscures authz reasoning).</p>
</li>
</ul>
<p><strong>Validation and input handling.</strong></p>
<ul>
<li><p><code>z.any()</code> in Zod schemas, bypasses validation entirely.</p>
</li>
<li><p>Missing validation middleware on POST/PUT/PATCH routes.</p>
</li>
<li><p>SQL string concatenation in repositories, parameterised queries only.</p>
</li>
<li><p>File upload endpoints without size limits, MIME type checks, or content-type validation.</p>
</li>
<li><p>SSRF risk: any handler that fetches arbitrary user-provided URLs.</p>
</li>
</ul>
<p><strong>Rate limiting and abuse vectors.</strong></p>
<ul>
<li><p>Auth endpoints (<code>/login</code>, <code>/register</code>, <code>/forgot-password</code>) without per-IP and per-identity rate limits.</p>
</li>
<li><p>429 responses without <code>Retry-After</code> headers.</p>
</li>
<li><p>Forgot-password endpoint that returns different responses for existing vs non-existing emails (enumeration leak).</p>
</li>
<li><p>Login lockout missing after N failures.</p>
</li>
</ul>
<p><strong>Webhook and signature verification.</strong></p>
<ul>
<li><p>Webhook handlers without HMAC signature verification.</p>
</li>
<li><p>HMAC comparison with <code>===</code> instead of <code>crypto.timingSafeEqual</code> (timing attack risk).</p>
</li>
<li><p>Missing replay-attack protection on webhooks (no <code>event_id UNIQUE</code> constraint).</p>
</li>
</ul>
<p><strong>Financial and money handling.</strong></p>
<ul>
<li><p>Money fields stored as <code>number</code> / <code>float</code> / <code>DECIMAL</code> instead of <code>bigint</code> kobo/cents.</p>
</li>
<li><p>Floating-point arithmetic on monetary values.</p>
</li>
<li><p>Wallet ledger missing append-only enforcement (UPDATE/DELETE on <code>wallet_entries</code>).</p>
</li>
<li><p>Missing reconciliation check between cached balance and ledger sum.</p>
</li>
</ul>
<p><strong>Logging and PII.</strong></p>
<ul>
<li><p>Loggers without redaction config: must redact <code>req.body.password</code>, <code>req.body.otp</code>, <code>*.password_hash</code>, <code>*.refresh_token</code>, <code>authorization</code>, BVN/SSN/national-ID fields, full PAN.</p>
</li>
<li><p>Error stack traces returned to the client in production.</p>
</li>
<li><p>Console logs left in production code.</p>
</li>
</ul>
<p><strong>Storage and client-side concerns.</strong></p>
<ul>
<li><p>Authentication tokens in <code>localStorage</code> (must be HttpOnly cookies).</p>
</li>
<li><p>Any sensitive data in <code>localStorage</code> or <code>sessionStorage</code> unencrypted.</p>
</li>
<li><p>Missing <code>X-Frame-Options</code>, <code>Content-Security-Policy</code>, or <code>Strict-Transport-Security</code> headers (Helmet config audit).</p>
</li>
</ul>
<p><strong>Supply chain.</strong></p>
<ul>
<li><p><code>.npmrc</code> missing <code>minimum-release-age=10080</code> (the 7-day release-age guard against day-zero supply chain attacks).</p>
</li>
<li><p><code>npm audit</code> output parsed; high/critical findings filed as bugs.</p>
</li>
<li><p>Direct git dependencies (<code>"package": "github:user/repo"</code>) flagged for review.</p>
</li>
</ul>
<h3>What it executes (dynamically)</h3>
<p>After the static audit, the agent runs targeted live tests against the running server:</p>
<p><strong>Auth matrix.</strong> For every protected endpoint: no token → 401; expired token → 401 with <code>code: token_expired</code>; valid token wrong role → 403; refresh token reuse → 401 and all sessions revoked; token after account disable → 401 (tests <code>tokenVersion</code> invalidation).</p>
<p><strong>Authz fuzzer (in-progress, see "what's next").</strong> For every endpoint that takes a resource ID, the agent attempts the request with a valid token belonging to a <em>different</em> user. Expected: 403 or 404 (whichever the project's convention says). Any 200 is an IDOR finding filed at P0.</p>
<p><strong>Rate limit storm.</strong> For each rate-limited route, the agent fires N+10 requests in a tight loop. Expected: 429 after N requests, with <code>Retry-After</code> header and <code>X-RateLimit-Remaining: 0</code>. Missing headers or unbounded responses are filed as findings.</p>
<p><strong>Idempotency triple test.</strong> For each endpoint that accepts <code>Idempotency-Key</code>:</p>
<ul>
<li><p>First call with key K → creates resource</p>
</li>
<li><p>Second call with key K, same body → returns identical response, no duplicate DB record</p>
</li>
<li><p>Third call with key K, different body → 422 <code>idempotency_mismatch</code></p>
</li>
</ul>
<p>The agent verifies all three states in the database with <code>psql</code> / <code>mongosh</code> and checks Redis for the cached idempotency key.</p>
<p><strong>Webhook replay.</strong> For each webhook endpoint, the agent re-fires a previously-processed event. Expected: 200 with no side-effect re-execution (idempotent), and the <code>event_id</code> UNIQUE constraint catches the duplicate.</p>
<p><strong>Financial ledger reconciliation.</strong> For any wallet/ledger operations, the agent runs the reconciliation query:</p>
<pre><code class="language-sql">SELECT
  (SELECT balance_kobo FROM wallet_balances WHERE wallet_id = $1) AS cached,
  (SELECT SUM(amount_kobo) FROM wallet_entries WHERE wallet_id = $1) AS ledger_sum;
</code></pre>
<p>Cached and <code>ledger_sum</code> must match. Any divergence is filed at P0.</p>
<p><strong>Enumeration check.</strong> For <code>/forgot-password</code> and similar endpoints, the agent submits both a known-existing and a known-non-existing email and compares the responses. Must be identical (same status, same body, same timing within ~50ms).</p>
<h3>What it produces</h3>
<p>A structured report with findings tagged by severity:</p>
<table>
<thead>
<tr>
<th>Severity</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td><strong>P0</strong></td>
<td>Active vulnerability. Credentials exposed, authz bypassed, IDOR confirmed, money math broken, secrets leaked. Block the deploy.</td>
</tr>
<tr>
<td><strong>P1</strong></td>
<td>Latent vulnerability. Missing rate limit on auth endpoint, refresh token rotation broken, webhook signatures not timing-safe, PII in logs. Fix before next release.</td>
</tr>
<tr>
<td><strong>P2</strong></td>
<td>Hardening gap. Missing CSP header, missing security headers, weak password parameters. Fix this sprint.</td>
</tr>
<tr>
<td><strong>P3</strong></td>
<td>Code quality with security implication. <code>any</code> types in auth code, missing input validation that the schema layer is currently catching but shouldn't have to. Backlog.</td>
</tr>
</tbody></table>
<p>Every finding includes: file + line, observed behaviour, expected behaviour, root-cause hypothesis, and a suggested fix specific enough that the dev agent can act on it without asking questions.</p>
<h3>What it has actually caught</h3>
<p>Sanitised postmortems from real audits:</p>
<p><strong>CI credential leakage</strong>: a webhook test passed locally with real production credentials and failed in CI with the network blocked. The credentials had been in CI environment variables for three weeks. Rule installed: external service credentials are never in CI env vars. All third-party services are stubbed in test environments.</p>
<p><strong>HTTP status info leak</strong>: <code>/auth/verify-otp</code> was returning 404 for "OTP not found" and 410 for "OTP expired" different responses leaked whether the OTP was ever generated. Fix: collapse to a single 410 <code>otp_invalid</code> regardless of cause.</p>
<p><strong>Service/HTTP coupling enabling an authz hole</strong>: a service method was accepting <code>req</code> to read <code>user_id</code>. A test was passing a forged <code>req</code> to the service in unit tests, which masked the fact that the production controller wasn't actually enforcing the auth check the test was relying on. Rule: services must never accept <code>req</code>. Use <code>requestContext.getStore()</code>.</p>
<p><strong>Route shadowing</strong>: <code>/api/v1/me</code> returning 404 because <code>/api/v1/:userId</code> was registered first. The auth middleware was on the parameterized route, not on <code>/me</code>, so <code>/me</code> was accidentally public. Fix: register specific routes before parameterised ones; the audit now greps every router file for ordering.</p>
<p><strong>Float-precision money bug</strong>: a calculation produced <code>99.99999999998</code> instead of <code>100</code>. The bug surfaced in a balance reconciliation diff. Rule: all monetary values are <code>bigint</code> kobo or cents, branded with a <code>Kobo</code> type that prevents accidental conversion to <code>number</code>.</p>
<p><strong>Webhook signature timing attack vector</strong>: webhook signature comparison was using <code>===</code> instead of <code>crypto.timingSafeEqual</code>. Theoretical risk, but the agent catches it as a P1 regardless. Fixed across every webhook handler.</p>
<h3>How it composes with the other QA agents</h3>
<p>The security agent doesn't replace the API QA agent: it runs alongside it. The API QA agent verifies functional correctness. The security agent verifies that the same endpoints can't be abused. Both file findings into the same report format with the same severity scheme.</p>
<p>When both agents are run on a feature branch, the merged output is the security posture of the change. The dev agent doesn't merge until both reports are clean (or the findings are explicitly accepted with a comment in the rules-lessons doc explaining why).</p>
<p>The full spec for what the security agent loads and runs is at <a href="https://github.com/spiderocious/agentic-workflow/blob/main/skills/security-review.md"><code>skills/security-review.md</code></a>. It's the most opinionated skill in the repo because security is the area where being right matters most.</p>
<hr />
<h2>The Two-Agent Design System</h2>
<p>I ship design systems for the products I build. About 28 components each, full token systems, real scenes, the works. The output usually looks like the kind of thing a small in-house design team would produce after two months: except I do it solo, in a couple of days, with an AI pipeline.</p>
<p>The pipeline is two slash commands with non-overlapping jobs:</p>
<table>
<thead>
<tr>
<th>Agent</th>
<th>Slash command</th>
<th>Role</th>
<th>What it writes</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Designer</strong></td>
<td><a href="https://github.com/spiderocious/agentic-workflow/blob/main/commands/design-system-agent.md"><code>/design-system-agent</code></a></td>
<td>Picks a stance, runs discovery, builds an HTML spec with real scenes</td>
<td><code>design-system/projects/&lt;slug&gt;/</code> — HTML scenes + <code>_foundation.css</code> + variations + thumb</td>
</tr>
<tr>
<td><strong>Shipper</strong></td>
<td><a href="https://github.com/spiderocious/agentic-workflow/blob/main/commands/ship-design-system.md"><code>/ship-design-system</code></a></td>
<td>Translates that finished spec into a real React component library inside a target repo</td>
<td>Target-repo <code>src/.../ui/&lt;component&gt;/&lt;component&gt;.tsx</code>, extends <code>globals.css</code> + <code>tailwind.config.ts</code>, plus a migration doc</td>
</tr>
</tbody></table>
<p>Before getting into how they work, the question that comes up first.</p>
<h3>Why an Agent and Not Just Stitch (or v0, Lovable, Bolt)?</h3>
<p>I use Stitch. I covered it in Part 1. It's excellent at producing a single screen from a detailed brief. v0, Lovable, Bolt, similar tools, they're all good at the same thing: take a prompt, produce a beautiful-looking screen, ship.</p>
<p>That's not what a design system is.</p>
<p>A design system is <strong>one stance applied consistently across 28 components and 5+ real surfaces</strong>. A button that matches the input that matches the card that matches the table that matches the empty state. The same accent color, the same border radius logic, the same shadow scale, the same typography ramp, composed deliberately so that any combination of components looks like it belongs in the same product.</p>
<p>Stitch and the rest can produce a beautiful login screen. Ask them to produce a <em>consistent</em> login screen + dashboard + settings page + onboarding flow + empty states + the critical "delete account" modal, all in the same visual language, and you'll spend the rest of the week reconciling differences between outputs.</p>
<p>The design system agent solves a different problem: <strong>commit to one visual stance and apply it ruthlessly across an entire system.</strong> It picks one stance (from a catalogue of 25), runs a structured discovery to understand the product, and then builds every component, every state, every scene against that one stance. The output isn't a single mockup. It's a complete system that survives composition.</p>
<p>The other difference: the agent's output is built to be <em>shipped</em>, not just looked at. Stitch produces an image or a code snippet for one screen. The design agent produces a folder of HTML scenes (with foundation CSS, design tokens, all states, all variants) that the shipper agent then translates into a real React component library inside a real codebase. The chain ends with merged PRs, not screenshots.</p>
<h3>The Designer's Discipline</h3>
<p>The designer agent operates under a small set of rules that are non-negotiable. These exist because every one of them is something I learned by getting it wrong first.</p>
<p><strong>One stance, never blended.</strong> The first version of the agent let me say "modern flat with a touch of editorial and some Bauhaus accents." The output was generic. Indistinguishable from the default AI aesthetic. The fix was a hard constraint in the system prompt:</p>
<blockquote>
<p>If you find yourself reaching for "modern flat with a touch of editorial and some Bauhaus accents," stop. Pick one stance. Ship one stance. The user will reward conviction.</p>
</blockquote>
<p>The catalogue has 25 stances:- Bauhaus, Editorial Broadsheet, Brutalist Dossier, Japanese Minimalism, Soviet Poster, Surgical Paper, Risograph Print, Glassmorphic Studio, Industrial Telemetry, Cartographer Atlas, and others. Each one is a complete worldview: typography choices, color logic, geometric language, motion language. The agent picks one and commits. Half-baked blends produce half-baked output.</p>
<p><strong>Scenes, not catalogues.</strong> Most design system docs show components in isolation: here's a button on a white background, here's an input in the default state, here's a card with placeholder content. The designer agent rejects that. State variants are rendered inside <em>real situations</em>, the loading state of a transaction confirmation, the error state of a payment retry, the empty state of a new account's transactions tab. You can't judge a button until you see it inside the screen where it'll actually appear.</p>
<p><strong>No Bootstrap defaults.</strong> If the accent is Tailwind blue or the neutral is Slate, the agent has failed the brief. The constraint is in the prompt because it's the easiest place to drift. The default Tailwind palette is fine. It's also what every AI-generated UI defaults to, which is why all AI-generated UI looks the same. The agent must pick palettes that belong to <em>this</em> product, not to "modern SaaS aesthetic."</p>
<p><strong>The CRITICAL modal is mandatory.</strong> Every system has at least one irreversible action, delete account, cancel subscription, transfer funds, archive case file. Most design systems skip designing the modal for it, because the happy path is more fun to draw. The agent doesn't get to skip it. The CRITICAL modal is designed as part of every system.</p>
<p><strong>Speak plainly.</strong> The user (me, or whoever invokes the agent) is treated as non-technical. No "design tokens," "atomic components," "semantic colour pairs" without translation. The agent's job is to produce design judgment, not to lecture about design vocabulary.</p>
<h3>The Five-Act Flow</h3>
<p>The designer agent runs a structured flow, not a freeform chat:</p>
<p><strong>Act I: Discovery.</strong> 8-10 questions about the product. Skip anything the brief already answered. Output: a short discovery doc captured in <code>design-system/notes/&lt;slug&gt;/discovery-&lt;date&gt;.md</code>.</p>
<p><strong>Act II: Style proposal.</strong> Three stances from the catalogue, each with one paragraph explaining why <em>this stance</em> fits <em>this product</em>. Not "Bauhaus is a clean minimal style" — "Bauhaus fits because your product is about precision in financial decisions, and Bauhaus's geometric clarity reinforces that posture."</p>
<p><strong>Act III: Variation pick.</strong> The most important act. The agent builds a single <code>_variations.html</code> file showing A/B/C visual variations side-by-side for a handful of key surfaces (the dashboard, the form, the list, the empty state). I pick one. Or I say "A for everything except the list row, give me three alternatives just for that." The agent iterates until the foundation is locked.</p>
<p><strong>Act IV: Build.</strong> In order: foundation CSS first (tokens, type scale, geometry, motion), then primitives (buttons, inputs, badges, selects), then data display (tables, cards, charts), then 3+ named surface scenes, then overlays (modals, toasts, tooltips, the CRITICAL modal).</p>
<p><strong>Act V: Register.</strong> The agent appends the project to <code>projects.json</code>, mirrors to the inline <code>window.__PROJECTS__</code> for the Studio gallery, and builds a <code>thumb.html</code> so the project shows up in the index.</p>
<p>The flow exists because design systems built in freeform chat drift. The five acts give the human (me) discrete review checkpoints: discovery, stance pick, variation pick, build progress, registration. I can intervene at any one. I don't have to discover three days later that the agent chose a stance I never approved.</p>
<h3>Why the Designer Output Looks Like a Designer Made It</h3>
<p>This is the part most AI design tools get wrong, and the part the agent gets right:</p>
<p><strong>Real content, not placeholder.</strong> Every scene uses plausible names, varied entities, believable values, earned non-round numbers. Actual names that vary in length, actual transactions with values like ₦48,250 and not ₦1,000,000.00, actual dates that are recent and sensibly spaced. The visual rhythm of the design depends on real content, because real content is what the design actually has to hold.</p>
<p><strong>Type does work.</strong> The agent picks a typography system where serif does the thinking ("Your monthly summary"), humanist sans does the chrome ("Settings · Notifications"), and mono does the record numbers (account IDs, transaction references). Three faces, three jobs, clear hierarchy. Not one face doing everything.</p>
<p><strong>Numbers shout.</strong> When the most important thing on a screen is a number, the agent makes that number visibly the loudest object. Bigger than the heading. Sometimes by a lot. The eye should land on it first without thinking about it.</p>
<p><strong>Red is reserved.</strong> Critical red is for irreversible actions and life-threatening conditions only. Amber for everything else that's "bad but recoverable." This single rule prevents the design from looking like a constant emergency.</p>
<p><strong>Hairlines, not shadows.</strong> When in doubt, draw a line. Shadows have become AI design's default decoration; they look fine at first and tired by week two. Hairlines age better.</p>
<p>These rules live in the agent's persistent memory (<code>design-system/notes/preferences.md</code>), which the agent reads at every session start. They were learned by doing, the first attempt at the medcord design system was rejected by me with the words "AI garbage, most AI shit ui design I've ever seen." The second attempt, after the stance discipline was added, got "shit this is goooood." That delta is the value of the rules.</p>
<h3>The Shipper's Discipline</h3>
<p>The shipper has one hard rule, stated in four separate places in its system prompt because it's the rule the agent most wants to violate:</p>
<blockquote>
<p><strong>Never invent design, only translate.</strong></p>
</blockquote>
<p>The shipper takes the HTML the designer built and produces a real React component library. It does not improve, embellish, or "modernise" what the HTML shows. If the HTML has six button variants, the React lib has six button variants. If the HTML doesn't show a hover state on the cards, the React lib doesn't add one. If a prop has multiple reasonable shapes (controlled vs uncontrolled, portal vs inline), the shipper surfaces the question, doesn't guess.</p>
<p>The other rule: <strong>don't fight the repo.</strong> The shipper calibrates against the target codebase before writing anything. It detects the framework (React/Vue/Solid/Svelte), the version, the styling system, the path aliases, the file layout, whether components use named or default exports, where <code>cn</code> is imported from. If the existing convention contradicts the shipper's preference, <em>theirs wins</em>. The new components must look like they belong in the repo, not like they were dropped in from a different project.</p>
<p>This sounds obvious. It's the easiest thing for an AI to violate. Without the discipline, the shipper writes "improved" code that nobody asked for and nobody can review against the spec.</p>
<h3>The Six-Act Ship Flow</h3>
<p>The shipper runs its own structured flow, with checkpoints that prevent it from running away:</p>
<p><strong>Act 0: Silent reads.</strong> Reads the Studio project's foundation CSS, the key HTML scenes, the migration guide. No output.</p>
<p><strong>Act I: Calibrate (read-only).</strong> Detects everything about the target repo: framework, version, styling, conventions. Reports findings. <strong>Stops.</strong> Waits for me to confirm or correct the detected conventions before doing anything else.</p>
<p><strong>Act II: Discovery.</strong> About 8 questions, each with a calibration-inferred default. Press enter to accept. Questions cover: component directory, naming, prop conventions, controlled vs uncontrolled defaults, portal preferences.</p>
<p><strong>Act III: Component plan.</strong> Writes a structured plan listing all ~28 components grouped by category. <strong>Stops.</strong> Waits for me to say "go" before writing any code.</p>
<p><strong>Act IV: Generate.</strong> In order: tokens (extend <code>globals.css</code>), Tailwind config extension, <code>cn</code> util if missing, then components in checklist order with a checkpoint every 5 components. After each component is generated, it is <strong>immediately added to the preview/viewer page</strong> with all its props, variants, and states shown.</p>
<p><strong>Act V: Wire up.</strong> Asks before any <code>pnpm add</code> or <code>npm install</code>. Writes a migration doc at <code>&lt;target&gt;/docs/&lt;slug&gt;-MIGRATION.md</code> explaining what was added and any follow-up steps.</p>
<p><strong>Act VI: Notes &amp; report.</strong> Appends <code>notes/&lt;target-repo-name&gt;/shipped-&lt;date&gt;.md</code> capturing what shipped, what was deferred, and any lessons learned.</p>
<p>The act structure is the safety mechanism. Every act ends with either a checkpoint (waiting for me) or a permission gate (asking before mutating). The shipper cannot run away, there are at least three places where the human is the one who unblocks the next step.</p>
<h3>The Incremental Preview Lesson</h3>
<p>The most important operational rule in the shipper, learned the hard way:</p>
<p>When building a component library, after building <strong>each single component</strong> the agent must immediately add it to the preview/viewer page, with all its props, variants, and states shown as samples, then move to the next component.</p>
<p>Not "build all 28 components, then wire all the previews at the end." Per-component preview, sequentially.</p>
<p>The reason: <strong>the preview is where I review. A component that isn't in the preview yet is invisible to me.</strong> Batching previews to the end leaves a long blind stretch where work piles up unreviewed, and by the time the previews land, I'm reviewing 28 components at once with no ability to course-correct any single one.</p>
<p>The "no sub-agents" qualifier follows from the same logic. Parallelising component generation across agents would re-introduce the blind stretch (I can't watch two threads at once), plus it removes my ability to scroll the main-thread transcript and audit step by step. <strong>The constraint is on observability, not on speed.</strong> The shipper could go faster with parallel agents. It would also become impossible to trust.</p>
<h3>Permission-Gated Mutations</h3>
<p>The shipper has explicit guardrails on what it can change without asking:</p>
<ul>
<li><p><strong>Never write a file before I say "go" in Act III.</strong> Calibration is read-only.</p>
</li>
<li><p><strong>Never overwrite an existing file without showing the diff first.</strong> This applies to <code>tailwind.config.*</code>, <code>globals.css</code>, <code>package.json</code>, <code>main.tsx</code>. Diff, then ask.</p>
</li>
<li><p><strong>Never run shell side effects (install, format, test) without asking.</strong> Show the exact command, wait for confirmation.</p>
</li>
<li><p><strong>Never skip git hooks or signing.</strong> Even if pre-commit fails, fix the cause; don't bypass.</p>
</li>
</ul>
<p>These exist because the shipper is operating inside <em>my real codebase</em>. A mistake at this layer isn't a bad Figma export, it's a corrupted Tailwind config or a botched component file that breaks the app.</p>
<h3>What This Produces</h3>
<p>A complete design system shipped to a real React codebase in about two days, end to end:</p>
<ul>
<li><p>~28 components, all in the same visual stance</p>
</li>
<li><p>Design tokens wired into the target's Tailwind config</p>
</li>
<li><p>A preview/viewer page showing every component with every variant and state</p>
</li>
<li><p>A migration doc explaining how to consume it</p>
</li>
<li><p>Persistent memory entries in <code>notes/&lt;target-repo-name&gt;/shipped-&lt;date&gt;.md</code> capturing what was built and what was deferred</p>
</li>
</ul>
<p>The components look like a designer made them, because the design discipline lives in the designer agent, the implementation discipline lives in the shipper agent, and neither one is asked to do the other's job.</p>
<p>The full how-to: <a href="https://github.com/spiderocious/agentic-workflow/blob/main/docs/how-to-use-design-system.md"><code>docs/how-to-use-design-system.md</code></a>. Both system prompts and the act-by-act flow are open source.</p>
<hr />
<h2>Multi-Agent Orchestration: When to Fork, When Not To</h2>
<p>I do <strong>not</strong> treat sub-agents as the default. The pattern across my work is <strong>single-thread + persona swap</strong>, not parallel agent swarms except when demanded:</p>
<h3>Multi-agent / persona handoff is allowed when</h3>
<ul>
<li><p>Work crosses a <strong>discipline boundary</strong> with a hard artifact at the seam: backend → backend QA → frontend → frontend QA. Each persona produces a structured handoff doc. The handoff <em>is</em> the shared context.</p>
</li>
<li><p><strong>Long-running unattended creative work</strong> (e.g. a batch of demo films). Pre-decided fallback ladders so the agent has authority to keep moving without me.</p>
</li>
</ul>
<p>The nuance: <strong>sub-agents are fine when the user is <em>absent</em> and the artifact-at-handoff is well-defined. Sub-agents are forbidden when the user is <em>watching</em> or when shared mutable state (browser session, preview page, live-edited repo) can't survive a fork.</strong></p>
<h3>How agents hand off</h3>
<p>Three layers, in priority order:</p>
<p><strong>Layer 1: Handoff documents (canonical).</strong> Every persona has a templated handoff format. The <a href="https://github.com/spiderocious/agentic-workflow/blob/main/personas/fullstack.md">fullstack persona</a> ships two distinct templates (Frontend QA + Backend QA) plus a <strong>Contract Drift Checklist</strong> that runs at the seam: Zod schema field names match frontend TS type field names exactly, nullable fields match, pagination shape matches, money fields are integers, dates are ISO 8601 strings, empty arrays are <code>[]</code> not <code>null</code>, error handlers check <code>error.code</code> not <code>error.message</code>.</p>
<p><strong>Layer 2: Persistent memory graph.</strong> In Claude Code, <code>~/.claude/projects/.../memory/</code> stores cross-linked memory files. Memories named for <strong>what triggered them</strong> (<code>feedback-ship-preview-incremental</code>, <code>agent-browser-demo-gotchas</code>). I expect the AI to "traverse memory graph-style, not read each file in isolation."</p>
<p><strong>Layer 3: Project guides as a read-order.</strong> Project-level <code>agent-handoff.md</code> files are addressed "to the next AI agent... read this once and operate at the same quality bar from the first message" and prescribe a numbered must-read order through the project's docs. Handoff is not a chat, it's a curriculum.</p>
<p><strong>Anti-pattern:</strong> plain-text "here's what I was doing" handoffs. Everything is structured. Always.</p>
<h3>The browser is the source of truth</h3>
<p>The orchestration safety net:</p>
<ul>
<li><p>QA frontend persona never reports PASS without browser verification.</p>
</li>
<li><p>AI tools lie about success. The agent-browser memory explicitly says: "reports success but the file lands elsewhere." Mitigation: verify state, don't trust default success signals.</p>
</li>
<li><p>The reload-to-verify-persistence step in the standard QA loop is the circuit breaker. After every mutation, reload and re-read the DOM.</p>
</li>
</ul>
<p>When the AI's report disagrees with the browser, <strong>the browser wins</strong>.</p>
<hr />
<h2>The Demo Director: AI for Things I'm Bad At</h2>
<p>A meta-pattern: I build agents to fix my own weaknesses.</p>
<p>The longest persona in the repo is <a href="https://github.com/spiderocious/agentic-workflow/blob/main/personas/demo-director.md"><code>demo-director.md</code></a> at ~27KB. It's the persona for the work I don't know how to do, marketing demos and launch films. The persona file is rich because the gap between my native ability and the required output is wide. Personas grow proportional to the gap.</p>
<p>The Demo Director's identity (verbatim):</p>
<blockquote>
<p>You are the Demo Director. Your job is to make a product <em>look as good as it actually is</em>, and, when the moment calls for it, to make it look like a launch film. You are a product-marketing-minded frontend engineer with a cinematographer's eye and a motion designer's hands. You do not build features — you reveal them.</p>
<p>Your taste is <strong>clean, cinematic, art-directed, honest, and luxurious.</strong></p>
</blockquote>
<h3>The hard-won rule</h3>
<blockquote>
<p>The naive approach, drive the live app and <code>agent-browser record</code> it produces a <strong>dashcam, not a film</strong>: it captures every dead moment while the agent thinks, has no authored pacing, no animations between states, no pointer, no zoom, no callouts, no audio. <strong>Do not make films this way.</strong> It was tried and it sucked.</p>
<p>Instead, films are <strong>rendered deterministically</strong>. The renderer is <strong>Remotion</strong> (React → MP4).</p>
</blockquote>
<h3>The architecture</h3>
<pre><code class="language-plaintext">agent-browser =  RECON: scrape the real app's markup + computed styles + screenshots + real data

React (Remotion) =  REBUILD the UI faithfully as self-contained components (no cross-tree imports)
                     ANIMATE with Remotion interpolate/spring (+ Framer Motion where its ergonomics help)

Director Kit = reusable primitives: &lt;SceneCard&gt; &lt;Caption&gt; &lt;Pointer&gt; &lt;Spotlight&gt; &lt;ZoomTo&gt; &lt;Reveal&gt;

script.ts = the screenplay: ordered scenes, durations, copy, pointer paths, highlight targets, audio cues

Remotion render = frame-perfect MP4; audio muxed in; then ffmpeg for MP4 = GIF if needed
</code></pre>
<p>The three data strategies (always present all, recommend one, <strong>ask before building</strong>):</p>
<ol>
<li><p><strong>Use the product's real/built-in data.</strong> Most honest path.</p>
</li>
<li><p><strong>Mock at the network layer</strong>: <code>agent-browser network route "**/api/..." --body '{...}'</code>.</p>
</li>
<li><p><strong>Seed real data</strong> via the app's own flows/seed scripts, then drive the real UI.</p>
</li>
</ol>
<p>The guardrails:</p>
<blockquote>
<p>Films are rendered, never screen-recorded. Honest, always. Dramatization and set dressing allowed; fabricating features is not. Recreate from the real thing, not from memory. Retina or it didn't happen. Stills at scale 2. Films at 2x density / 1080p+. No <code>test test</code> data, ever.</p>
</blockquote>
<p>Deeper dive in a future article. The point for now: the persona that codifies the work I'm bad at is the most elaborate persona in the repo. That's the meta-pattern, <strong>personas grow proportional to the gap between your ability and the work.</strong></p>
<hr />
<h2>Self-Built Skills: agent-browser, Director Kit</h2>
<p>Two skills I built when no existing tool fit.</p>
<h3>agent-browser</h3>
<p>A CLI tool that exposes a persistent Chromium daemon as bash commands. It's the foundation of the QA frontend agent and the Demo Director's recon step.</p>
<p>Why I rely on a tool of this shape: Playwright, Cypress, and Puppeteer are built for humans writing test files in their DSL. That's the right shape for a regression suite. It's the wrong shape for an AI agent doing exploratory testing in the middle of a conversation.</p>
<p><code>agent-browser</code> lets the AI agent drive the browser in plain Bash. The agent thinks in shell commands, not in test framework idioms. That made the QA agent's prompts dramatically shorter and meaningfully more reliable — and it doesn't conflict with Playwright at all. The agent uses Playwright for the regression suite it's running, and <code>agent-browser</code> for the exploratory work it's doing on top.</p>
<p>The full reference is in <a href="https://github.com/spiderocious/agentic-workflow/blob/main/skills/agent-browser.md"><code>skills/agent-browser.md</code></a>. The field guide for QA usage is in <a href="https://github.com/spiderocious/agentic-workflow/blob/main/skills/agent-browser-qa-guide.md"><code>skills/agent-browser-qa-guide.md</code></a>.</p>
<h3>The Director Kit</h3>
<p>A set of React primitives the Demo Director composes into scenes: <code>&lt;SceneCard&gt;</code>, <code>&lt;Caption&gt;</code>, <code>&lt;Pointer&gt;</code> (bezier paths + click pulses), <code>&lt;Spotlight&gt;</code> (dim to focus), <code>&lt;ZoomTo&gt;</code> (camera moves), <code>&lt;Shape&gt;</code>, <code>&lt;AudioCue&gt;</code>, <code>&lt;Reveal&gt;</code>, <code>&lt;BrowserFrame&gt;</code>.</p>
<p>Each primitive composes deterministically against Remotion's frame clock. The Demo Director persona doesn't have to think about animation math; it composes primitives, and the math is correct by construction.</p>
<p>The Director Kit isn't open-sourced separately yet. The patterns it encodes are in the Demo Director persona.</p>
<hr />
<p>What I'd Build Next Honest gaps in the current system, in order of impact:</p>
<ol>
<li><p>A landing-page persona. The chain has a hole between Demo Director output and the actual landing page repo. Right now I build landing pages ad hoc. Codifying the pattern (which Director assets go where, what copy structure converts, how the AI-discoverability layer plugs in) would close it.</p>
</li>
<li><p>A "fresh repo onboarding" agent persona. "I'm a new agent in a Feranmi project, what do I read in what order", currently project-specific via agent-handoff.md files. A universal version would reduce session-start friction.</p>
</li>
</ol>
<p>The <strong>Honest Conclusion The "AI as force multiplier" narrative</strong> is real. But it works for me because I've been bitten enough times to know what to encode. It's the practice (bug → rule → spec → next agent inherits it). The files are just where the practice lives.</p>
<p>The files are at <a href="http://github.com/spiderocious/agentic-workflow">github.com/spiderocious/agentic-workflow</a>. Fork it. Edit it. Ignore what doesn't apply. The whole point of the modular structure is to make that easy.</p>
<p>What I hope this <a href="https://crackedchefs.devferanmi.xyz/series/ai-llm-coding">two-part series</a> did is show one specific way to use AI seriously in production, not as a toy, not as a magic box, but as a contractor with a reference library of your team's lessons. The reference library is the work. The AI is the means.</p>
<p>The AI doesn't make you a better engineer. It executes the engineer you already are, at higher throughput. Encode the engineer you want to be.</p>
<p>Bye.</p>
]]></content:encoded></item><item><title><![CDATA[How I Use AI to Code Effectively, Part 1]]></title><description><![CDATA[A few weeks ago I shipped a feature on a Friday afternoon. Bank statement parser, deterministic tax engine, full chat tier with conversation threading, the works. Backend service layer, frontend scree]]></description><link>https://crackedchefs.devferanmi.xyz/how-i-use-ai-to-code-effectively-part-1</link><guid isPermaLink="true">https://crackedchefs.devferanmi.xyz/how-i-use-ai-to-code-effectively-part-1</guid><dc:creator><![CDATA[Oluwaferanmi Adeniji]]></dc:creator><pubDate>Mon, 09 Feb 2026 08:00:00 GMT</pubDate><content:encoded><![CDATA[<p>A few weeks ago I shipped a feature on a Friday afternoon. Bank statement parser, deterministic tax engine, full chat tier with conversation threading, the works. Backend service layer, frontend screens, contract tests at the seam, QA pass against a live server, design system components added to the preview as each one was built. The PR cleared review with two comments, both nits. The deploy hit production at 6pm. Monday morning, zero bug reports.</p>
<p>I didn't write most of the code. AI agents did. I planned the work with Opus, executed the implementation with Sonnet, ran QA passes with two more agents driving a real browser and a live API. The output was indistinguishable from what I'd write by hand, except faster, and with better test coverage than I usually have the patience for.</p>
<p>It's because the AI is loaded with context before it writes a single line, every bug my team has ever shipped to production, every API convention, every banned pattern, every "we tried this in March and it didn't work." The agent walks into the session already knowing what the senior engineer in the room would have told it.</p>
<p>I've evolved this system across the products I ship — TaxLens, TrustRail, Medcord, Solon, Pracket, WorkSight, Ohlify, and others. It's not a framework or a tool. It's a collection of markdown files and a discipline for using them. It treats the AI agent as a <strong>recurring contractor on a maturing codebase</strong>, not a one-shot magic box. Each session inherits the accumulated rule set. Each session contributes new rules back when it finds new failure modes.</p>
<p>The result, after about 18 months of iteration:</p>
<ul>
<li><p><strong>Velocity</strong> — I ship features end-to-end in hours that used to take days. The bottleneck has moved from typing speed to thinking speed, which is where it should have always been.</p>
</li>
<li><p><strong>Consistency</strong> — code from the AI looks like code I'd write, because the AI is reading the same conventions I follow. Reviewers can't usually tell which lines were mine and which were the agent's.</p>
</li>
<li><p><strong>Reduced regression rate</strong> — recurring bug classes (the 204 <code>.json()</code> parsing bug, API drift, money-as-float, <code>useEffect + fetch</code> races) don't recur. The rules that prevent them are loaded on every session.</p>
</li>
<li><p><strong>Agentic QA</strong> — every feature gets an agent-driven QA pass before I touch it. Browser-driven for the frontend, live API for the backend, with a PASS/FAIL/SKIP/BLOCKED report I read in two minutes.</p>
</li>
<li><p><strong>Audit trail</strong> — every architectural decision is in a markdown file. When the next engineer (human or AI) asks why something is the way it is, the answer is one <code>grep</code> away.</p>
</li>
</ul>
<p>This is Part 1 of two articles on the system. Today: how I plan with AI, how I spec-drive development, how I manage context across long sessions, and how I run code review at scale. <a href="https://crackedchefs.devferanmi.xyz/how-i-use-ai-to-code-effectively-part-2">Part 2</a> covers the QA agents, the design system pipeline, and the tools I've built around all of it.</p>
<p>The full system is open source at <a href="https://github.com/spiderocious/agentic-workflow">github.com/spiderocious/agentic-workflow</a>. Every persona, skill, and example I'll reference below is in that repo. Open it in a tab and follow along.</p>
<p>Let's dig in.</p>
<hr />
<h2>Why This Works (and Why the Default Doesn't)</h2>
<p>Before showing the mechanics, the foundational claim:</p>
<p>The default AI coding workflow is <em>engineer types a task description, agent generates code, engineer reviews and ships</em>. It works for trivial tasks. For anything non-trivial in a codebase with history, it produces plausible-looking code that violates conventions, repeats fixed bugs, and forces the engineer to spend more time reviewing than they saved by delegating.</p>
<p>The agent isn't wrong because it's bad at coding. It's wrong because it doesn't know what your team knows. It doesn't know that money is stored as <code>bigint</code> kobo, never float. It doesn't know that 204 responses break <code>.json()</code> parsing. It doesn't know that the auth middleware must come before the role-check middleware. It doesn't know that two engineers ago, someone added a route at <code>/api/v1/me</code> that was silently shadowed by <code>/api/v1/:userId</code> and the team spent three days debugging it.</p>
<p>All of that knowledge exists somewhere — in the heads of the engineers, in PR comments, in postmortems, in Slack threads, in <code>git blame</code>. None of it is loaded into the agent's context at the start of a session.</p>
<p>My system is built on a single inversion of that default: <strong>encode the lessons in files the agent reads on every session</strong>. Make the institutional memory available to the contractor on day one, every day. Everything else in this article is mechanics for doing that well.</p>
<hr />
<h2>The Three-Layer Model</h2>
<p>The system has three layers. Each does one thing.</p>
<pre><code class="language-plaintext">──────────────────────┐
│  LAYER 1: PERSONA                                            │
│  Who the AI is. Identity + invariants + which skills to     │
│  load. Small files (3-30KB).                                                  ──────────────────────┘
                        
            "Before writing code, load these:"
                         ▼
──────────────────────┐
│  LAYER 2: SKILL                                              │
│  HOW to do specific work. Detailed playbooks. Tool          │
│  references, code patterns, lint rules. Reusable across     │
│  multiple personas. Files range from 10KB to 40KB.          │
──────────────────────┘
                          │
                "Reference codebase docs:"
                          ▼
──────────────────────┐
│  LAYER 3: CODEBASE                                           │
│  Your repo. The personas point to your project's docs/      │
│  folder as the source of truth for "what good looks like    │
│  here."                                                       │
──────────────────────┘
</code></pre>
<p>A persona is <strong>who</strong> the AI is — a senior backend engineer, a QA engineer specialising in frontend, a mobile-aware API designer.<br />A skill is <strong>how</strong> the AI does specific work — write a service method, drive a real browser for QA, design an API endpoint with mobile consumption in mind.<br />The codebase is <strong>what</strong> the AI is working on, with project-specific docs that describe local conventions.</p>
<p>The separation is load-bearing.<br />Five reasons it matters:</p>
<p><strong>1. Skills are reusable, personas are roles.</strong> <a href="https://github.com/spiderocious/agentic-workflow/blob/main/skills/hard-lessons.md"><code>hard-lessons.md</code></a> is loaded by 6 different personas — backend, frontend, fullstack, mobile, qa-backend, qa-frontend. If a hard lesson lived inside any single persona file, the other five would drift away from it. Separation enforces single source of truth.</p>
<p><strong>2. Personas can be tiny.</strong> The smallest persona in the repo is <a href="https://github.com/spiderocious/agentic-workflow/blob/main/personas/frontend.md"><code>frontend.md</code></a>. It's basically: "you are a senior frontend engineer, load these five skills, here are your guardrails." The persona's job is to <strong>select and orient</strong>, not to redefine.</p>
<p><strong>3. Skills can be large without bloating context.</strong> <a href="https://github.com/spiderocious/agentic-workflow/blob/main/skills/backend-qa-agent.md"><code>backend-qa-agent.md</code></a> is 40KB. If that lived inside the QA backend persona file, every "audit my API" invocation would burn 40KB of context. Instead, the persona is small and references the big skill — the agent only loads the heavy file when it actually needs it.</p>
<p><strong>4. QA personas can audit dev personas' work using the same rulebook.</strong> <a href="https://github.com/spiderocious/agentic-workflow/blob/main/personas/qa-backend.md"><code>qa-backend.md</code></a> and <a href="https://github.com/spiderocious/agentic-workflow/blob/main/personas/backend.md"><code>backend.md</code></a> both load <a href="https://github.com/spiderocious/agentic-workflow/blob/main/skills/backend-service-patterns.md"><code>backend-service-patterns.md</code></a>. The QA agent greps the codebase for violations of the <em>same rules</em> the dev agent was told to follow. No translation step. No divergence.</p>
<p><strong>5. Personas encode order and tone; skills encode correctness.</strong> A persona says "you think in this order: data model → service contract → HTTP surface." That's a working-style claim. A skill says "services return <code>ServiceResult&lt;T&gt;</code>, never throw." That's a rule. Different kinds of content; different files.</p>
<p>The full mental model is written up in <a href="https://github.com/spiderocious/agentic-workflow/blob/main/docs/how-personas-work.md"><code>docs/how-personas-work.md</code></a> in the repo.</p>
<hr />
<h2>Specs Are Not Documentation. They're Working Memory.</h2>
<p>The single most impactful change in how I work with AI is treating <strong>specs as the source of truth</strong> that AI agents read, execute against, and update, not as documentation written after the fact.</p>
<p>The operating model:</p>
<ul>
<li><p>Prose lives in a docs folder</p>
</li>
<li><p>Code lives in the project repo</p>
</li>
<li><p>AI agents shuttle between them</p>
</li>
</ul>
<p>The docs aren't a description of what the code does. They're a manual addressed to the next AI agent that will work on this project. They include verbatim instructions like "Pattern-match this file before writing any code. Every API path, response unwrap, request body field, icon name, and meemaw pattern is pre-verified here against the actual backend source. Do not guess."</p>
<p>There are five kinds of spec documents, each with a different audience:</p>
<table>
<thead>
<tr>
<th>Document</th>
<th>Addressed to</th>
<th>What it captures</th>
</tr>
</thead>
<tbody><tr>
<td><code>prd.md</code> / <code>mvp.md</code></td>
<td>The product mind (me)</td>
<td>Scope, user stories, deferrals</td>
</tr>
<tr>
<td><code>*-build-plan.md</code></td>
<td>The next AI implementer</td>
<td>Per-module file structure, exact API endpoints</td>
</tr>
<tr>
<td><code>phase-N-spec.md</code></td>
<td>The next AI implementer mid-build</td>
<td>Pre-verified gotchas, banned wrong-guesses</td>
</tr>
<tr>
<td><code>*-handoff.md</code></td>
<td>The next AI agent on a fresh session</td>
<td>Repo layout, must-read order</td>
</tr>
<tr>
<td><code>rules-lessons.md</code></td>
<td>Every future agent</td>
<td>"Every rule here was learned by breaking it"</td>
</tr>
</tbody></table>
<p>There's a real example of each in the repo's <a href="https://github.com/spiderocious/agentic-workflow/tree/main/examples"><code>examples/</code></a> folder — including a <a href="https://github.com/spiderocious/agentic-workflow/blob/main/prds/sample.md">sample PRD</a> at the repo root that shows the bare-minimum format.</p>
<hr />
<h2>The Spec</h2>
<p>Every MVP spec is a bulleted list of user-story sentences in the exact form <code>"A user can / a user must / a user will be able to ..."</code>. Each one is testable, demoable, and pointable-to in a QA report.</p>
<p>From the <a href="https://github.com/spiderocious/agentic-workflow/blob/main/examples/mvp-spec.md"><code>examples/mvp-spec.md</code></a> file:</p>
<pre><code class="language-markdown">## Module 1 — Upload

- A user can choose between two input paths: Upload a CSV statement or Try with sample data.
- A user must accept the privacy notice before upload becomes available.
- A user can upload a CSV file up to 10MB. Larger files must be rejected with a clear message.
- A user must see a real-time progress indicator while the file uploads and parses.
- A user will be able to see how many transactions were detected immediately after parsing.
</code></pre>
<p>Each bullet is one unit of work. The pattern (<code>A user can / must / will be able to</code>) is itself the test contract.</p>
<p>Here's the trick — and this is the part most spec-driven workflows miss:</p>
<p><strong>The MVP features becomes the QA test cases which then becomes the test assertion.</strong> Same thing, three lifecycles.</p>
<p>In the MVP:</p>
<blockquote>
<p>A user can upload a CSV file up to 10MB.</p>
</blockquote>
<p>In the QA handoff (see <a href="https://github.com/spiderocious/agentic-workflow/blob/main/examples/frontend-qa-handoff.md"><code>examples/frontend-qa-handoff.md</code></a>):</p>
<blockquote>
<p>On this screen, the user must be able to upload a CSV file up to 10MB.</p>
</blockquote>
<p>In the test script:</p>
<pre><code class="language-js">await test('A-UP-03', 'Upload rejects files over 10MB', async () =&gt; {
  const res = await postFile('/statements/upload', oversizedFile);
  assertStatus(res, 413);
});
</code></pre>
<p>One sentence, no translation loss. This is the cheapest way to keep specs and tests in sync. If the MVP says it, the QA tests it, end of debate.</p>
<hr />
<h2>The Pre-Flight Checklist Pattern</h2>
<p>MVP specs describe scope. <strong>Phase specs front-load gotchas.</strong></p>
<p>A technical documentation phase spec is a pre-verified execution manual that pattern-matches every corners/patterns the agent will hit. The opener tells the agent what kind of document it's reading:</p>
<blockquote>
<p><em>Pattern-match this file before writing any code for any feature in phases 4, 5, or 6. Every API path, response unwrap, request body field, icon name, and meemaw pattern is pre-verified here against the actual backend source. Do not guess.</em></p>
</blockquote>
<p>The structure of a phase spec:</p>
<ul>
<li><p><strong>Mandatory checklist before every feature</strong> — 10–15 boxes the agent must tick</p>
</li>
<li><p><strong>Verified primitives</strong> — icon names, EP constants, response shapes — explicitly marked WRONG vs CORRECT</p>
</li>
<li><p><strong>Cheat tables</strong> for every endpoint mapping URL → service return → frontend unwrap</p>
</li>
<li><p><strong>Banned patterns</strong> with wrong/right code pairs</p>
</li>
</ul>
<p>The example phase spec at <a href="https://github.com/spiderocious/agentic-workflow/blob/main/examples/phase-spec.md"><code>examples/phase-spec.md</code></a> shows an API drift table like this:</p>
<table>
<thead>
<tr>
<th>Action</th>
<th>Correct path</th>
<th>EP constant</th>
<th>Match?</th>
</tr>
</thead>
<tbody><tr>
<td>List transactions</td>
<td><code>GET /api/v1/statements/${id}/transactions</code></td>
<td><code>EP.STATEMENT_TRANSACTIONS(id)</code></td>
<td>OK</td>
</tr>
<tr>
<td>Reclassify</td>
<td><code>PATCH /api/v1/statements/\({id}/transactions/\){txnId}</code></td>
<td><code>EP.STATEMENT_RECLASSIFY</code> → <code>/reclassify</code></td>
<td><strong>WRONG</strong> (no <code>/reclassify</code> suffix)</td>
</tr>
<tr>
<td>Bulk reclassify</td>
<td><code>POST /api/v1/statements/${id}/transactions/bulk-reclassify</code></td>
<td><code>EP.STATEMENT_BULK_RECLASSIFY(id)</code></td>
<td>OK</td>
</tr>
</tbody></table>
<p>This is the spec doing <strong>AI-prep work</strong>: instead of letting the agent re-derive every endpoint, I front-load the drift map so the agent burns zero cycles on solved problems.</p>
<p>It feels excessive when you're writing it. It pays back massively the first time the next agent reads it and avoids three obvious bugs.</p>
<hr />
<h2>Bugs Become Rules</h2>
<p>The atomic operation of the whole system is: every bug shipped to production becomes a rule the next agent must follow.</p>
<p>From a sanitized rules-lessons doc:</p>
<blockquote>
<p><em>### 1. Trace the full response chain before writing any hook</em></p>
<p><em>Before writing a hook, trace this exact path:</em> <em>1. Read the route handler — what does</em> <code>ResponseUtil.ok(res, X)</code> <em>pass?</em> <em>2. Read the service — what does the method return?</em> <em>3. Know the envelope:</em> <code>ResponseUtil.ok(res, data)</code> <em>→</em> <code>{ data }</code><em>.</em></p>
<p><em>Examples from real mistakes:</em> <code>ts* *// Route: ResponseUtil.ok(res, result) where result = { items, total, page, limit, totalPages }* *// WRONG: r.data.data / r.data.meta.total* *// RIGHT: r.data.items / r.data.total* *</code></p>
<p><em>Never assume. Read it.</em></p>
</blockquote>
<p>That <code>// Examples from real mistakes:</code> line is the literal moment a bug becomes a permanent agent-facing rule.</p>
<p>The graduation path:</p>
<ol>
<li><p>Bug ships</p>
</li>
<li><p>Engineer fixes it, writes a one-paragraph entry in the project's <code>rules-lessons.md</code></p>
</li>
<li><p>Every future AI session on that project reads <code>rules-lessons.md</code> before writing code</p>
</li>
<li><p>If the same bug pattern shows up in a second project, it graduates to the workspace-level <a href="https://github.com/spiderocious/agentic-workflow/blob/main/skills/hard-lessons.md"><code>skills/hard-lessons.md</code></a></p>
</li>
<li><p>Every persona that loads <code>hard-lessons.md</code> (six of them) now knows the pattern</p>
</li>
</ol>
<p>This is the loop that makes the system get harder to break with each cycle. The bugs you've already had become the bugs you don't have anymore.</p>
<p>A full example of a project's rules-lessons file is at <a href="https://github.com/spiderocious/agentic-workflow/blob/main/examples/rules-lessons.md"><code>examples/rules-lessons.md</code></a>. The workspace-level version is at <a href="https://github.com/spiderocious/agentic-workflow/blob/main/skills/hard-lessons.md"><code>skills/hard-lessons.md</code></a>.</p>
<hr />
<h2>Planning with Opus, Executing with Sonnet</h2>
<p>Now to the part most people get wrong: <strong>context management</strong>.</p>
<p>Long sessions degrade. The model gets confused. Earlier decisions get forgotten. Code drifts from the spec. The agent that started crisp ends up making mistakes that the same model in a fresh session would never make.</p>
<p>My pattern, after a lot of trial and error:</p>
<p><strong>Use the most capable model (Opus, currently 4.7 / 4.8) for planning. Use the fastest competent model (latest Sonnet) for execution.</strong></p>
<p>This isn't a cost-saving move. It's a context-management move.</p>
<h3>Planning sessions (Opus)</h3>
<p>When I start a new feature or a new phase, I open a fresh session with Opus and ask for three deliverables in sequence:</p>
<ol>
<li><p><strong>High-level plan</strong> — "Here's the MVP spec. What are the modules, the order, the dependencies between them?"</p>
</li>
<li><p><strong>Detailed plan</strong> — "Let's drill into Module 2. What are the API endpoints, the frontend screens, the data model changes? Where are the gotchas?"</p>
</li>
<li><p><strong>Tech docs</strong> — "Write the phase spec. Include the API drift table, the icon registry, the banned patterns."</p>
</li>
</ol>
<p>Opus is genuinely better at this kind of work — holding many constraints in mind, sensing where the design will break, asking clarifying questions before producing the plan. The output of these three steps is a set of markdown files (<code>high-level-plan.md</code>, <code>phase-2-spec.md</code>, <code>migration-notes.md</code>) that go into the project's docs folder.</p>
<p>Then I close the session. The plan is the deliverable, not the chat history.</p>
<h3>Execution sessions (Sonnet)</h3>
<p>For implementation, I open a fresh session with the latest Sonnet and point it at the plan:</p>
<blockquote>
<p>"Load <code>personas/backend.md</code> and follow it. Read the skills it lists. Then implement Module 2 per the spec in <code>docs/phase-2-spec.md</code>. The relevant rules-lessons are in <code>docs/rules-lessons.md</code>."</p>
</blockquote>
<p>Sonnet executes faster, has plenty of context window for the actual implementation work, and produces clean code when the plan is good. The plan does the heavy thinking; Sonnet does the typing.</p>
<h3>Clear context between tasks</h3>
<p>The single best practice I've adopted: <strong>open a new chat for every task</strong>. Not "every feature" — every task.</p>
<p>Re-using the same chat across tasks pollutes the context. The agent remembers the previous task's decisions and applies them to the new one even when they don't transfer. Worse, the agent develops a kind of momentum — it stops re-reading the spec because "we already discussed it" — and starts inventing.</p>
<p>A fresh chat per task feels wasteful. It isn't. The cost of re-loading personas and skills (maybe 30 seconds of agent reading) is trivial next to the cost of the agent making a confused decision in turn 47 of a 60-turn chat because it conflated something from turn 12 with the current task.</p>
<h3>Run the quality gates between every workflow</h3>
<p>Before I hand control back to a fresh session, I run the project's quality gates myself in the previous one:</p>
<pre><code class="language-bash">pnpm typecheck
pnpm lint
pnpm test --changed
pnpm build
</code></pre>
<p>All four must pass. If they don't, the new session starts with a broken baseline, and the agent will spend the first 10 minutes trying to figure out why things are red instead of doing the work I'm asking for.</p>
<p>This is the rule the <a href="https://github.com/spiderocious/agentic-workflow/blob/main/skills/quality-standards.md"><code>quality-standards.md</code></a> skill enforces, and it applies to humans too. <strong>Don't hand a broken state to the next agent.</strong></p>
<hr />
<h2>Claude Settings That Actually Matter</h2>
<p>A few things in my <code>.claude/settings.json</code> that genuinely move the needle:</p>
<p><strong>Permissions allowlist.</strong> Default Claude prompts you to approve every Bash command. After the 200th <code>git status</code>, you stop reading the prompts. I add the read-only commands I run constantly to an allowlist so they don't prompt — <code>ls</code>, <code>find</code>, <code>grep</code>, <code>git status</code>, <code>git log</code>, <code>git diff</code>, <code>cat</code>, <code>pnpm typecheck</code>. The prompts that remain are the ones that actually need attention.</p>
<p><strong>Hooks for automation.</strong> Claude Code lets you register hooks that run on specific events — <code>SessionStart</code>, <code>PreToolUse</code>, <code>Stop</code>. I use a <code>Stop</code> hook to play a sound when a long-running task finishes so I can context-switch productively. I use <code>SessionStart</code> hooks for project-specific setup ("if you're in this repo, load this persona automatically").</p>
<p><strong>The hard "never" list in CLAUDE.md.</strong> Every project has a <code>CLAUDE.md</code> at the root with project-specific rules the agent reads on every session start. Mine includes things like "never bypass git hooks," "never <code>--force</code> push to main," "always run typecheck before claiming done."</p>
<p>The actual settings.json is project-specific, but the doctrine is in the <a href="https://github.com/spiderocious/agentic-workflow/tree/main/skills"><code>skills/</code></a> and <a href="https://github.com/spiderocious/agentic-workflow/tree/main/commands"><code>commands/</code></a> folders — particularly <a href="https://github.com/spiderocious/agentic-workflow/blob/main/skills/rules.md"><code>rules.md</code></a> which captures the universal workspace rules (pnpm only, <code>workspace:*</code> for internal deps, 7-day npm release age, Node 20+, path aliases only).</p>
<hr />
<h2>Why Grep Beats LLM-Only Review at Scale</h2>
<p>Now to the second half of the system: code review.</p>
<p>A typical AI code review pattern is "ask the LLM to read every changed file and find issues." This works for small diffs. It does not scale. At any real codebase size, a reviewer that reads every file end-to-end is slow, expensive, and inconsistent — the same code reviewed on Monday and Friday produces different findings.</p>
<p>My reviewer is <strong>multi-axis</strong> and runs across three persona lenses (backend, frontend, fullstack QA). Each axis has concrete grep recipes and verbatim violation patterns.</p>
<h3>The four axes</h3>
<p><strong>Logic &amp; correctness</strong> — strict TS, no <code>any</code>, no <code>useEffect + fetch</code>, no <code>as</code> casts, money as <code>bigint</code> only.</p>
<p><strong>Security</strong> — every route in <code>asyncHandler</code>, auth middleware on protected routes, no <code>z.any()</code>, no real credentials in CI, refresh token reuse detection tested.</p>
<p><strong>Performance</strong> — bundle optimization checks, server/client component boundary checks (Next.js), database index audits, N+1 query patterns, append-only ledger violations.</p>
<p><strong>Consistency</strong> — pnpm only, <code>workspace:*</code> for internal deps, 7-day release age on deps, path aliases only, no cross-app imports, no Redux/Zustand, icon proxy enforced.</p>
<h3>The grep recipes</h3>
<p>For each axis, the QA personas have grep recipes that catch the common violations in seconds. From <a href="https://github.com/spiderocious/agentic-workflow/blob/main/personas/qa-backend.md"><code>personas/qa-backend.md</code></a>:</p>
<pre><code class="language-bash"># Services that throw (must return ServiceResult&lt;T&gt;)
grep -rn "throw new\|throw new Error" src/features/ --include="*.service.ts" \
  | grep -v "//\|AppError\|ValidationError"

# req object passed into service calls (HTTP leaking into business logic)
grep -rn "service\.\(.*\)(req\|\.service\.\(.*\)(.*req" src/features/

# res.json() called directly (bypasses ResponseUtil envelope)
grep -rn "res\.json\|res\.send(" src/features/ | grep -v "ResponseUtil"

# Async route handlers without asyncHandler wrapper
grep -rn "router\.\(get\|post\|put\|patch\|delete\)(.*async" src/features/ \
  | grep -v "asyncHandler"

# any type in Zod schemas
grep -rn "z\.any()" src/

# Money stored as number/float (must be bigint)
grep -rn "amount.*: number\|balance.*: number\|price.*: number" src/
</code></pre>
<p>From <a href="https://github.com/spiderocious/agentic-workflow/blob/main/personas/qa-frontend.md"><code>personas/qa-frontend.md</code></a>:</p>
<pre><code class="language-bash"># Raw &amp;&amp; in JSX (must be &lt;Show when={...}&gt;)
grep -rn "{.*&amp;&amp;" src/features/ --include="*.tsx"

# Raw .map() in JSX (must be &lt;Repeat&gt;)
grep -rn "\.map(" src/features/ --include="*.tsx"

# Direct lucide-react imports (must go through icon proxy)
grep -rn "from 'lucide-react'" src/features/

# Raw hex in Tailwind classNames (must use token classes)
grep -rn "bg-\[#\|text-\[#\|border-\[#" src/features/
</code></pre>
<p>Each recipe catches a known class of bug in milliseconds. The LLM is reserved for the 20% — semantic review, cross-file architectural sniff tests, "does this approach make sense given the rest of the codebase?"</p>
<p>The pattern: <strong>grep catches the patterns; the LLM catches the meaning</strong>. Both, never just one.</p>
<h3>The slash commands</h3>
<p>Built-in Claude Code commands I use constantly:</p>
<ul>
<li><p><code>/code-review</code> with an effort dial (<code>low / medium / high / max / ultra</code>). Low and medium produce a small number of high-confidence findings. <code>Ultra</code> runs a multi-agent review in the cloud.</p>
</li>
<li><p><code>/code-review --comment</code> posts findings as inline PR comments.</p>
</li>
<li><p><code>/simplify</code> — equivalent to <code>/code-review --fix</code>. Applies the findings to the working tree.</p>
</li>
<li><p><code>/review</code> — review a pull request.</p>
</li>
<li><p><code>/security-review</code> — security review of pending changes on the current branch.</p>
</li>
<li><p><code>/verify</code> — verify a change actually works by running the app and observing behavior.</p>
</li>
</ul>
<p>Pre-commit Husky hooks enforce the bare minimum (typecheck, lint, format, test on staged files) before commits land. CI fails if coverage of changed files drops below 80%. The reviewer fires both at PR creation and in the local diff.</p>
<hr />
<h2>MCP: Extending the Agent's Reach</h2>
<p>Beyond the personas and skills, I use <strong>MCP servers</strong> (Model Context Protocol) to give the agent access to systems outside the local filesystem.</p>
<p>The four I rely on:</p>
<h3>GitHub / GitLab MCP</h3>
<p>Lets the agent read PRs, issues, comments, and commit history directly — without me copy-pasting. The pattern: "Read PR #142 and tell me what's still unresolved in the review thread." The agent fetches the PR, reads every comment, identifies which threads are open vs resolved, and produces a summary. The same thing for issues — "What did we decide about pagination in issue #88?"</p>
<p>This sounds small. It compounds. When the agent can read the surrounding context (the PR description, the linked issue, the review comments) before writing code, the code it writes accounts for the discussion.</p>
<h3>Atlassian MCP (Jira, Confluence)</h3>
<p>The same pattern, applied to ticket systems. "Read JIRA ticket PROJ-2417 and produce the implementation plan." The agent fetches the ticket, the acceptance criteria, any linked design docs in Confluence, and the comment thread. Then it writes the plan against all of that, not just my one-line summary.</p>
<p>I'm cautious about giving the agent write access to Jira — I prefer to keep the human in the loop on ticket transitions. But read access has been transformative.</p>
<h3>Figma MCP</h3>
<p>For design-driven work. The agent reads the Figma file, extracts the component structure, the design tokens, the spacing, the variants. Then it implements against the actual design, not against my verbal description of it.</p>
<p>This is especially good for design system work (more in <a href="https://crackedchefs.devferanmi.xyz/how-i-use-ai-to-code-effectively-part-2">Part 2</a>). Combined with the <code>/ship-design-system</code> slash command, the agent can take a Figma file and translate it into a real React component library with high fidelity. The Figma MCP is what makes "the AI implements the design directly" actually feasible.</p>
<h3>Video Watcher MCP</h3>
<p>Less famous but genuinely useful. Lets the agent watch a video file (a recording, a tutorial, a Loom walkthrough) and produce a transcript-plus-screenshots summary. The pattern: "Here's a 15-minute Loom walkthrough of the bug. Watch it and tell me what's happening." The agent watches, extracts the relevant frames, transcribes the narration, and produces a description that includes the visual context.</p>
<p>This works for design reviews too — "Watch this Figma prototype recording and tell me the user flow." Massively faster than me writing it up.</p>
<h3>What I don't use MCP for</h3>
<p>I don't use MCP for things that need to happen at scale or in CI. The MCP servers are for interactive work — augmenting the agent's context with information from external systems. The actual code execution, the actual tests, the actual deploys still happen through the standard local tooling. MCP is a context-extension layer, not an execution layer.</p>
<hr />
<h2>Stitch Design and Claude Design</h2>
<p>Two more tools that have changed the design-implementation loop:</p>
<h3>Stitch Design (AI design generation)</h3>
<p>Stitch is an AI design tool by Google that produces production-grade UI mockups from prompts. The trick is that AI design tools work best when given:</p>
<ol>
<li><p>Screen-by-screen breakdowns — not full page descriptions</p>
</li>
<li><p>Extremely detailed specifications — every element, size, color, position</p>
</li>
<li><p>Light mode specifications — always specify light/dark mode</p>
</li>
<li><p>Exact measurements — pixel values, percentages, spacing units</p>
</li>
</ol>
<p>The Golden Rule: <strong>if you haven't explicitly stated it, the AI will guess — and it will probably guess wrong.</strong></p>
<p>I use Stitch as the front of a chain: prompt Stitch with a detailed brief → get a mockup → feed the mockup into Figma → use the Figma MCP to have Claude implement it. The chain converts "I have an idea for a screen" into shipped React code in an afternoon, with quality that would have taken a week the old way.</p>
<h3>Claude Design (the design system commands)</h3>
<p>The two slash commands in my repo — <a href="https://github.com/spiderocious/agentic-workflow/blob/main/commands/design-system-agent.md"><code>/design-system-agent</code></a> and <a href="https://github.com/spiderocious/agentic-workflow/blob/main/commands/ship-design-system.md"><code>/ship-design-system</code></a> — are a clean designer/implementer split:</p>
<ul>
<li><p><code>/design-system-agent</code> (the <strong>designer</strong>) — picks a stance from a catalog of 25, runs discovery, builds the HTML spec (scenes + foundation CSS + variations)</p>
</li>
<li><p><code>/ship-design-system</code> (the <strong>shipper</strong>) — translates a finished Studio project into a real React component library inside a target repo</p>
</li>
</ul>
<p>The shipper's hard rule, stated in four places in its system prompt: <strong>never invent design — only translate.</strong> If the HTML doesn't show a state, the shipper doesn't add one. Don't fight the repo (if existing components use default exports, the shipper uses default exports too).</p>
<p>The full workflow is documented in <a href="https://github.com/spiderocious/agentic-workflow/blob/main/docs/how-to-use-design-system.md"><code>docs/how-to-use-design-system.md</code></a>. The second <a href="https://crackedchefs.devferanmi.xyz/how-i-use-ai-to-code-effectively-part-2">part</a> of this article series goes deeper into the design system pipeline — how the two-agent split keeps design and implementation honest.</p>
<hr />
<h2>The Vercel React Skills and the UI Skills</h2>
<p>Two more layers that load on every frontend task:</p>
<h3>Vercel React skills</h3>
<p>A set of three skills from Vercel that I treat as universal frontend canon:</p>
<ul>
<li><p><code>vercel-react-best-practices</code> — performance optimization guidelines, ~70 rules across categories like <code>async-</code>, <code>bundle-</code>, <code>server-</code>, <code>client-</code></p>
</li>
<li><p><code>vercel-composition-patterns</code> — React composition patterns that scale (compound components, render props, context providers, React 19 API changes)</p>
</li>
<li><p><code>vercel-react-view-transitions</code> — guide for native View Transition API implementations</p>
</li>
</ul>
<p>These are not my skills. Vercel built them. They install via Claude Code's skill system (or symlink into <code>~/.claude/skills/</code>). Together they give the agent a strong default for "what good React looks like in 2026" without me having to encode it.</p>
<h3>UI / Web Design Guidelines skill</h3>
<p>A general-purpose UI review skill. Useful for catching accessibility regressions, contrast issues, keyboard navigation gaps, the things that should not ship but routinely do because the developer was looking at the happy path on a 32-inch monitor.</p>
<p>These don't replace my <a href="https://github.com/spiderocious/agentic-workflow/blob/main/skills/frontend-fsd.md"><code>frontend-fsd.md</code></a> and <a href="https://github.com/spiderocious/agentic-workflow/blob/main/skills/frontend-guide.md"><code>frontend-guide.md</code></a> — those are the project-specific rules. The Vercel skills are universal patterns; mine are project-specific conventions. Both load.</p>
<hr />
<h2>What This Architecture Gives Up</h2>
<p>Honest accounting, because the system isn't free:</p>
<p><strong>Onboarding overhead.</strong> A new engineer copying this scaffolding without my scar tissue will encode the wrong things. The lesson is the <em>practice</em> (bug → rule → spec → next agent inherits it), not the specific files. Copying my files without the discipline is cargo-culting.</p>
<p><strong>Doc drift.</strong> Specs go stale. The agent reading a stale spec doesn't know the spec is wrong. I don't have a drift detector yet — a weekly job that re-greps the codebase and flags rule drift would keep the specs honest. It's on my "what I'd build next" list.</p>
<p><strong>Grep recipes are codebase-specific.</strong> The recipes in <code>qa-backend.md</code> assume Express + service layer + ResponseUtil envelope. If your stack is different (NestJS, Fastify, Go, Rust), the recipes don't transfer directly — you need to rewrite them for your patterns. The methodology transfers; the specific commands don't.</p>
<p><strong>Cross-project promotion of lessons is manual.</strong> When a bug pattern appears in two projects, I have to notice it and lift the rule from project-specific <code>rules-lessons.md</code> to workspace-level <code>hard-lessons.md</code>. There's no automation for this. A monthly "lift recurring lessons" review is on the backlog.</p>
<p><strong>It only works because I've been bitten enough times to know what to encode.</strong> The system is the externalised memory of an engineer who has seen the failure modes. A junior engineer running this exact scaffolding will encode their first month's mistakes — which are not the same mistakes the system was designed to catch.</p>
<hr />
<h2>Evaluation</h2>
<p>After running this system across the products I ship, three measurable properties:</p>
<p><strong>New project starts faster.</strong> The universal skills (<code>hard-lessons</code>, <code>quality-standards</code>, <code>rules</code>, <code>frontend-fsd</code>, <code>backend-service-patterns</code>) are already loaded. The agent doesn't need to be told that money is <code>bigint</code> kobo or that services return <code>ServiceResult&lt;T&gt;</code>. It already knows. First-week velocity on a new project is meaningfully higher.</p>
<p><strong>Recurring bug classes don't recur.</strong> The 204 <code>.json()</code> parsing bug, the API drift bug, the <code>req</code>-in-service bug, the optimistic update without rollback bug — these used to appear in every project. They don't anymore. They're in the skill files. The agent reads the skills. The bugs don't get written.</p>
<p><strong>QA agents and dev agents grep against the same rulebook.</strong> Handoffs don't require translation. The dev agent was told "services return <code>ServiceResult&lt;T&gt;</code>." The QA agent greps for <code>throw new</code> in service files and files violations. Same rule, different lens.</p>
<hr />
<h2>The Closing Frame</h2>
<p>The AI is a recurring contractor on a maturing codebase. Each session inherits the accumulated rule set. Each session contributes new rules back when it finds new failure modes.</p>
<p>The persona is <strong>who</strong> the contractor is. The skill is <strong>how</strong> the contractor works. The codebase docs are <strong>what</strong> the contractor needs to know about this specific project.</p>
<p>When you treat AI like a stranger you have to re-explain everything to, you get vacuum-problem output. When you treat AI like a contractor with a reference library of your team's lessons, you get something different.</p>
<p>The reference library is at <a href="https://github.com/spiderocious/agentic-workflow">github.com/spiderocious/agentic-workflow</a>. Fork it. Edit it. Ignore the parts that don't apply. The files are deliberately small and modular so you can take what fits.</p>
<p><strong>The spec is not documentation. It's the executable working memory of the team.</strong></p>
<p>In <a href="https://crackedchefs.devferanmi.xyz/how-i-use-ai-to-code-effectively-part-2">Part 2:</a> the QA agents, the design system pipeline, the multi-agent orchestration patterns, the self-built skills like <code>agent-browser</code> and the Demo Director persona, and what I'd build next.</p>
]]></content:encoded></item><item><title><![CDATA[Two-Tier LLM Pipelines: Cost Firewalls for Production AI]]></title><description><![CDATA[The first time you check your OpenAI bill after a real traffic spike, something changes in you permanently. It's not the number itself it's the realisation that every engineering decision you made in ]]></description><link>https://crackedchefs.devferanmi.xyz/two-tier-llm-pipelines-cost-firewalls-for-production-ai</link><guid isPermaLink="true">https://crackedchefs.devferanmi.xyz/two-tier-llm-pipelines-cost-firewalls-for-production-ai</guid><category><![CDATA[llm]]></category><category><![CDATA[AI]]></category><category><![CDATA[cost-optimisation]]></category><dc:creator><![CDATA[Oluwaferanmi Adeniji]]></dc:creator><pubDate>Fri, 09 Jan 2026 09:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5d5009ed2db7c7fb3cd7cf28/cbecd032-b2a8-4bbc-9b16-fd7ecb96b476.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The first time you check your OpenAI bill after a real traffic spike, something changes in you permanently. It's not the number itself it's the realisation that every engineering decision you made in development, every "just call the API" shortcut, every missing cache, is now a line item that scales with your users.</p>
<p>I've shipped two AI-backed products TaxLens, which analyses bank statements to estimate Nigerian income tax, and TrustRail, which underwrites BNPL applications from the same kind of documents. Both run GPT-4o in production. Both have cost architectures that are deliberately designed, not discovered after the fact. This is what those architectures look like and why they're shaped the way they are.</p>
<p>Let's dig in.</p>
<hr />
<h2>The Economics of Naive AI Pipelines</h2>
<p>A naive AI pipeline has one design pattern: receive request, call the best model, return result. This works fine in development, where you're the only user and you're not watching the bill.</p>
<p>In production, the problems compound:</p>
<ul>
<li><p>Every user action maps to at least one expensive model call</p>
</li>
<li><p>Abuse or unusual usage patterns map to an unusual bill</p>
</li>
<li><p>OpenAI outages become your outages, with no graceful path</p>
</li>
<li><p>A spike in traffic produces a proportional spike in cost no ceiling, no buffer</p>
</li>
</ul>
<p>The cost model is fully linear and directly coupled to user behaviour. That's fine if your product has healthy unit economics on the AI spend. Most early-stage products don't.</p>
<p>The alternative isn't to avoid LLMs it's to design the pipeline so that expensive calls are gated, deferred, and fallback-protected. That's what "cost firewalls" means in practice.</p>
<hr />
<h2>The Gate Pattern: Pay for Validation, Not for Analysis</h2>
<p>The most impactful single change in both TaxLens and TrustRail was introducing a cheap validation call before the expensive extraction call.</p>
<p>In TaxLens, the pipeline is two sequential model calls:</p>
<p><strong>Tier 1 Gate</strong> (<code>OPENAI_GATE_MODEL</code>, a fast, cheap model):</p>
<pre><code class="language-typescript">const gate = await llmClient.structured({
  tier: 'gate',
  code,
  model: env.OPENAI_GATE_MODEL,
  system: GATE_SYSTEM,
  user: 'Validate this bank statement.',
  pdf: { filename, base64: pdfBase64 },
  schema: GateVerdictSchema,
  schemaName: 'gate_verdict',
});

if (!gate.data.valid) {
  emit(await taxProcessRepository.advance(code, 'failed', {
    failureReason: gate.data.reason || 'Not a usable Nigerian bank statement',
    gateResponseId: gate.responseId,
  }));
  return; // analysis call never fires
}
</code></pre>
<p><strong>Tier 2 Analysis</strong> (<code>OPENAI_ANALYSIS_MODEL</code>, a more capable model):</p>
<pre><code class="language-typescript">const analysis = await llmClient.structured({
  tier: 'analysis',
  code,
  model: env.OPENAI_ANALYSIS_MODEL,
  system: ANALYSIS_SYSTEM,
  user: 'Extract and classify the inflows, then annualise income.',
  pdf: { filename, base64: pdfBase64 },
  schema: AnalysisSchema,
  previousResponseId: gate.responseId,
  schemaName: 'statement_analysis',
});
</code></pre>
<p>The gate model answers a boolean question: is this document a real, legible Nigerian bank statement? If no, the pipeline terminates without ever touching the analysis model.</p>
<h3>The Cost Math</h3>
<p>Approximate costs (gpt-4o-mini for gate, gpt-4o for analysis, PDF inputs):</p>
<ul>
<li><p>Gate call: ~$0.002 per document</p>
</li>
<li><p>Analysis call: ~$0.06–0.08 per document</p>
</li>
</ul>
<p>At a 15% invalid document rejection rate (photos of receipts, foreign bank statements, blank pages, users testing with wrong files), the cost per 1,000 uploads:</p>
<ul>
<li><p>Without gate: 1,000 × \(0.07 = <strong>\)70</strong></p>
</li>
<li><p>With gate: (1,000 × \(0.002) + (850 × \)0.07) = <strong>$61.50</strong></p>
</li>
</ul>
<p>That's a 12% saving, which compounds. But the more important number is what the gate saves on abuse: an attacker or a confused user uploading 100 non-bank-statement PDFs costs \(0.20 with a gate, not \)7.00 without one. The gate is a rate firewall as much as a cost firewall.</p>
<h3>Condition: When the Gate Saves vs. Costs</h3>
<p>The gate adds latency: a sequential second call that doesn't run in parallel. On fast infrastructure with a cheap gate model, this adds ~300–600ms. If your rejection rate is under 5%, the gate may cost more in cumulative latency than it saves in analysis calls. The threshold depends on:</p>
<ul>
<li><p>Gate model price vs. analysis model price (higher ratio → lower rejection rate needed to break even)</p>
</li>
<li><p>Whether gate and analysis can share context via <code>previousResponseId</code> (TaxLens does this the analysis continues the conversation from the gate response, avoiding re-sending the PDF)</p>
</li>
<li><p>Your users' accuracy in uploading the right document type</p>
</li>
</ul>
<p>At a 10%+ rejection rate, the gate is unambiguously worth it. Below 5%, measure before committing.</p>
<hr />
<h2>The Queue as a Throughput Firewall</h2>
<p>TrustRail's cost architecture is different from TaxLens because the use case is different. TaxLens is interactive the user uploads and waits for a result in the same session. TrustRail is asynchronous a business submits an application, and the analysis runs in a background job.</p>
<p>The <code>statementAnalysisJob</code> runs every 60 seconds. It fetches a maximum of 10 pending applications (FIFO, oldest first) and processes them sequentially:</p>
<pre><code class="language-typescript">const pendingApplications = await Application.find({
  status: 'PENDING_ANALYSIS',
})
  .sort({ submittedAt: 1 })
  .limit(10);
</code></pre>
<p>This creates a hard throughput ceiling: at most 10 GPT-4o calls per minute, regardless of how many applications are submitted. A burst of 50 simultaneous submissions doesn't produce a burst of 50 simultaneous API calls it produces a queue that drains at a controlled rate over 5 minutes.</p>
<p>The cost implication: instead of "cost = f(submissions per second)", you get "cost = f(time)". The spend rate is predictable and bounded, independent of user behaviour spikes.</p>
<h3>The Queue vs. Direct Call Trade-off</h3>
<p>The queue imposes a latency penalty. An application submitted at the start of a busy minute might wait up to 10 minutes for its first analysis attempt if the queue depth is large. This is acceptable for TrustRail because the user experience is "we'll notify you when your application is processed" there's no interactive wait. It is not acceptable for TaxLens because the user is sitting on a loading screen.</p>
<p>The right pattern depends on whether your use case is request/response or fire-and-forget. If users must wait for the LLM response to continue, the queue is the wrong shape. If they submit and check back, the queue is exactly right.</p>
<hr />
<h2>The JS Fallback: The Zero-Cost Floor</h2>
<p>TrustRail has a pure TypeScript underwriting engine (<code>trustEngineService.ts</code>) that can analyse a CSV bank statement without calling any external model. It's the fallback path when OpenAI is unavailable.</p>
<p>The job checks which path to use:</p>
<pre><code class="language-typescript">if (application.openai?.fileId) {
  // Primary path: GPT-4o
  try {
    trustEngineOutput = await analyzeFileWithOpenAI(...);
  } catch (error) {
    // Fallback path: JS engine
    if (application.bankStatementCsvData) {
      trustEngineOutput = await analyzeApplication(application.applicationId);
    } else {
      throw error;
    }
  }
} else {
  // Legacy path: JS engine only
  trustEngineOutput = await analyzeApplication(application.applicationId);
}
</code></pre>
<p>The fallback doesn't just exist for availability it's the cost floor. During an OpenAI outage, the system keeps processing applications at $0 per analysis call. The JS engine is less capable (it can't read scanned PDFs, it relies on regex-based transaction classification rather than semantic understanding), but it produces a valid decision. Something beats nothing.</p>
<p>This creates a two-tier cost model:</p>
<ul>
<li><p>Normal operation: GPT-4o primary, ~$0.06–0.08 per application</p>
</li>
<li><p>Degraded operation: JS engine only, $0.00 per application</p>
</li>
</ul>
<p>The system never stops working. The cost never exceeds the primary path ceiling.</p>
<hr />
<h2>The Circuit Breaker: Protecting Against Cascade Cost</h2>
<p>TaxLens has a three-state circuit breaker wrapping every OpenAI call:</p>
<pre><code class="language-plaintext">closed → half_open → open
           ↑              |
           └──────────────┘ (cooldown)
</code></pre>
<p>The state machine: accumulate consecutive failures while <code>closed</code>. At the <code>failureThreshold</code> (configurable), transition to <code>open</code>. While <code>open</code>, every call fast-fails with <code>CircuitOpenError</code> no API call is made. After <code>cooldownMs</code>, transition to <code>half_open</code>. Let one probe through. Success → <code>closed</code>. Failure → back to <code>open</code>.</p>
<pre><code class="language-typescript">async run&lt;T&gt;(fn: () =&gt; Promise&lt;T&gt;): Promise&lt;{ result: T; stateAtCall: CircuitState }&gt; {
  const state = this.getState();

  if (state === 'open') {
    throw new CircuitOpenError(this.remainingCooldownMs());
  }
  if (state === 'half_open') {
    if (this.halfOpenInFlight) throw new CircuitOpenError(this.remainingCooldownMs());
    this.halfOpenInFlight = true;
  }

  try {
    const result = await fn();
    this.onSuccess();
    return { result, stateAtCall: state };
  } catch (err) {
    this.onFailure();
    throw err;
  }
}
</code></pre>
<p>The cost implication: a spike of failed requests during an OpenAI degradation event doesn't generate a proportional number of timeout-based API calls (which cost tokens even when they fail). After 3 consecutive failures, the remaining requests in that burst fast-fail locally in microseconds without touching the API.</p>
<p>At 100 simultaneous uploads during an outage, without a circuit breaker, you might generate 100 failing API calls with partial token consumption. With a circuit breaker, you generate 3 failing calls, then 97 local fast-fails. The cost difference at scale isn't trivial.</p>
<h3>What the Circuit Breaker Does Not Protect Against</h3>
<p>The current implementation is per-process, in-memory. On a single Node.js instance, it works correctly. On two instances behind a load balancer, each maintains independent state one may be <code>open</code> while the other is <code>closed</code>. A 50/50 load split means roughly half of requests still hit the API despite the circuit being "open" in aggregate terms.</p>
<p>This is documented in the design as a v2 concern. Moving <code>consecutiveFailures</code>, <code>state</code>, and <code>openedAt</code> to a shared store (Redis or MongoDB) would make the breaker instance-aware. For a product running on a single instance, the in-memory version is the right starting point no Redis dependency, no distributed locking, minimal latency overhead.</p>
<hr />
<h2>The Audit Repository: Observability as a Cost Instrument</h2>
<p>Neither cost architecture works without visibility. TaxLens records every LLM call to <code>llm_audit</code>:</p>
<pre><code class="language-typescript">export interface LlmAuditDoc {
  code: string;
  tier: LlmTier;       // 'gate' | 'analysis' | 'chat'
  model: string;
  requestId: string;
  promptHash: string;  // SHA-256 of system + user never raw text
  inputTokens: number;
  outputTokens: number;
  latencyMs: number;
  circuitState: CircuitState;
  error?: string;
  createdAt: Date;
}
</code></pre>
<p>This is not just observability it's a cost ledger. Queries against this collection answer:</p>
<ul>
<li><p>Which tier is consuming the most tokens? (Gate calling gpt-4o by mistake would be immediately visible.)</p>
</li>
<li><p>What's the median latency by model? (Useful for deciding which gate model to use.)</p>
</li>
<li><p>Are any <code>code</code> values accumulating unusually many <code>chat</code> tier calls? (A user asking 40 follow-up questions in one session is a unit economics anomaly worth catching.)</p>
</li>
<li><p>What fraction of calls have <code>circuitState: 'open'</code>? (If this is non-zero during business hours, the circuit threshold may need tuning.)</p>
</li>
</ul>
<p>The <code>promptHash</code> field is deliberate: it's a SHA-256 of <code>\({system}\n\){user}</code> not the raw statement content. The audit record proves the model was called and what it cost. It does not store PII. A regulator can verify the audit trail. A GDPR delete request doesn't require touching the audit collection.</p>
<hr />
<h2>The Chat Tier: Controlling Interactive Costs</h2>
<p>Both TaxLens and TrustRail have a chat feature users can ask follow-up questions about their results. This is the highest-risk tier for cost: a single user could send 50 questions. Each question is a model call.</p>
<p>TaxLens's <code>aiService.ask</code> uses conversation threading via <code>previousResponseId</code>:</p>
<pre><code class="language-typescript">const result = await llmClient.structured({
  tier: 'chat',
  code,
  model: env.OPENAI_CHAT_MODEL,
  system: SYSTEM,
  user: `\({context}\n\nQUESTION: \){question}`,
  schema: AnswerSchema,
  schemaName: 'grounded_answer',
  ...(process.analysisResponseId !== undefined
    ? { previousResponseId: process.analysisResponseId }
    : {}),
});
</code></pre>
<p>The <code>previousResponseId</code> chains the chat turn to the prior analysis response. This means the model doesn't need to re-receive the full bank statement and analysis context on every question it picks up where the previous turn left off. On a platform that caches prior responses server-side, this reduces prompt tokens on subsequent turns significantly.</p>
<p>The chat system prompt has one additional hard constraint:</p>
<blockquote>
<p><em>You may ONLY explain the computed numbers provided to you in the CONTEXT below. NEVER produce, estimate, or invent a tax figure that is not already in that context.</em></p>
</blockquote>
<p>This is both a grounding rule (from the previous article) and a cost rule. An LLM that freely computes new figures on demand will generate longer, more token-heavy responses. An LLM constrained to explain existing figures produces concise, bounded answers.</p>
<hr />
<h2>Trade-offs: What This Architecture Gives Up</h2>
<p><strong>Latency in the interactive case.</strong> The gate-then-analysis sequential structure adds at least one extra model round-trip before the user sees results. On a slow connection with a large PDF, the gate call alone can take 2–4 seconds. The total pipeline latency is gate + analysis + tax engine, not just analysis + tax engine.</p>
<p><strong>Complexity.</strong> A single-model pipeline is easier to reason about, debug, and test. Two models with different responsibilities, a circuit breaker, a queue, a fallback JS engine, and an audit trail is more surface area. Every component is simple in isolation, but the interactions between them require careful thought.</p>
<p><strong>Queue depth under load.</strong> The 10-per-minute processing ceiling in TrustRail means that during a large simultaneous submission event, applications wait. This is acceptable for asynchronous workflows and unacceptable for interactive ones. If TrustRail ever moves to synchronous approval, the queue architecture needs to be replaced or augmented.</p>
<p><strong>Per-process circuit breakers break under horizontal scale.</strong> Documented above. Not a problem for single-instance deployments. A real problem when you scale out.</p>
<hr />
<h2>Evaluation</h2>
<p>The gate pattern, combined with the circuit breaker and queue, produces three measurable properties:</p>
<p><strong>Cost predictability</strong> Spend is bounded by the queue throughput ceiling and the gate rejection rate. Neither varies wildly with unexpected user behaviour.</p>
<p><strong>Graceful degradation</strong> OpenAI unavailability does not produce user-visible errors in TrustRail (JS fallback) or runaway retry costs (circuit breaker). TaxLens users see a clear error message after the circuit opens, which is the right failure mode for an interactive product.</p>
<p><strong>Audit fidelity</strong> Every model call is logged with token counts and latency before the response is used. Cost visibility is a first-class product feature, not something reconstructed from OpenAI's dashboard after the fact.</p>
<hr />
<h2>The Mental Model</h2>
<p>Treat LLM calls the way you treat database writes: never make one per user action if you can gate, batch, cache, or defer.</p>
<p>The gate is a pre-write validation. The queue is a write buffer. The circuit breaker is a connection pool limit. The fallback engine is a read replica. These aren't novel AI infrastructure concepts they're standard distributed systems patterns applied to a dependency that happens to charge per request and has non-deterministic latency.</p>
<p>The bill is a feedback signal. If it's surprising, the architecture has a gap. <strong>Build the gap closed before the traffic arrives.</strong></p>
]]></content:encoded></item><item><title><![CDATA[ Testing Non-Deterministic Dependencies: Deterministic LLM Stubs That Preserve the Production Path ]]></title><description><![CDATA[There is a class of test suite that is worse than having no tests at all. It passes most of the time. It fails occasionally with no clear reason. It passes again after a retry. You trust it when it pa]]></description><link>https://crackedchefs.devferanmi.xyz/testing-non-deterministic-dependencies-deterministic-llm-stubs-that-preserve-the-production-path</link><guid isPermaLink="true">https://crackedchefs.devferanmi.xyz/testing-non-deterministic-dependencies-deterministic-llm-stubs-that-preserve-the-production-path</guid><dc:creator><![CDATA[Oluwaferanmi Adeniji]]></dc:creator><pubDate>Fri, 02 Jan 2026 08:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5d5009ed2db7c7fb3cd7cf28/915b7ce0-6b92-479e-b975-1f412e8ce204.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>There is a class of test suite that is worse than having no tests at all. It passes most of the time. It fails occasionally with no clear reason. It passes again after a retry. You trust it when it passes. You ignore it when it fails. And at some point, it misses a real regression because you've trained yourself to dismiss its failures as noise.</p>
<p>A test suite that calls GPT-4o directly is that class of test suite.</p>
<p>Not because language models are bad — they're not. But because a test that depends on a remote API with non-deterministic output, variable latency, and token-metered calls cannot be the thing you block deploys on. It will flake. It will cost money in CI. It will be wrong about the right things and right about the wrong things.</p>
<p>TaxLens has a solution to this problem that I think is worth writing about in detail: a stub transport that runs through the exact same circuit breaker, audit writer, Zod schema validation, and retry logic as the real OpenAI transport. Tests that use the stub are not testing a simplified mock. They're testing production code paths against controlled, steerable inputs.</p>
<p>Let's get cracking,</p>
<hr />
<h2>Where Non-Determinism Actually Lives</h2>
<p>This is the first question worth asking precisely. The entire TaxLens backend is not non-deterministic — only one seam is.</p>
<p>The seam is <code>openaiInvoke</code> in <code>openai-client.ts</code>:</p>
<pre><code class="language-typescript">const openaiInvoke: Transport = async (params) =&gt; {
  const client = getClient();
  const result = await client.responses.parse({
    model,
    input: [...],
    text: { format: zodTextFormat(schema, schemaName) },
  });

  return {
    parsed: result.output_parsed,
    responseId: result.id,
    inputTokens: result.usage?.input_tokens ?? 0,
    outputTokens: result.usage?.output_tokens ?? 0,
  };
};
</code></pre>
<p>Everything before and after this function is deterministic:</p>
<ul>
<li><p>The circuit breaker state machine (closed/open/half_open transitions on consecutive failures)</p>
</li>
<li><p>The Zod schema validation of the model's output</p>
</li>
<li><p>The repair-retry logic when output fails validation</p>
</li>
<li><p>The <code>llm_audit</code> writes (token counts, latency, circuit state)</p>
</li>
<li><p>The <code>taxProcessRepository</code> state transitions (validating → analyzing → ready/failed/needs_review)</p>
</li>
<li><p>The <code>compareRegimes</code> tax engine (pure TypeScript, no I/O)</p>
</li>
</ul>
<p>That means the vast majority of the system is fully testable without any model involvement. The non-determinism is localized to a single function that can be swapped at the boundary.</p>
<hr />
<h2>The Transport Interface</h2>
<p>The key architectural decision that makes all of this work is defining <code>Transport</code> as an explicit type:</p>
<pre><code class="language-typescript">export type Transport = &lt;T&gt;(params: StructuredCallParams&lt;T&gt;) =&gt; Promise&lt;TransportResult&gt;;
</code></pre>
<p>One line. Both <code>openaiInvoke</code> and <code>stubInvoke</code> implement this type. The shared shell — <code>runStructured</code> — receives a <code>Transport</code> and calls it. It never knows which one it has.</p>
<p>The selection happens once, at module initialization:</p>
<pre><code class="language-typescript">const transport: Transport = env.LLM_MODE === 'stub' ? stubInvoke : openaiInvoke;
</code></pre>
<p><code>LLM_MODE=stub</code> in test and CI environments. <code>LLM_MODE=live</code> in production. Everything else runs unchanged.</p>
<p>This is the seam. And the invariant the seam must preserve is: <strong>the stub must be indistinguishable from the real transport to the code that uses it.</strong> Same return type. Same error types. Same audit writes. The shared shell runs whether the transport is real or stubbed — which means the circuit breaker, the schema validation, and the retry logic are all exercised by tests that never call OpenAI.</p>
<hr />
<h2>The Stub Transport: Steerable, Not Hardcoded</h2>
<p><code>stub-transport.ts</code> is not a single fixed response. It's a state machine with five steering mechanisms, checked in priority order:</p>
<h3>1. Simulating a missing API key</h3>
<pre><code class="language-typescript">if (env.LLM_STUB_UNCONFIGURED) {
  return Promise.reject(new UpstreamUnavailableError('AI is not configured (stub: unconfigured)'));
}
</code></pre>
<p>Every call throws. This simulates the path where <code>OPENAI_API_KEY</code> is absent. The real <code>getClient()</code> function throws the same <code>UpstreamUnavailableError</code> for the same reason. Tests that set <code>LLM_STUB_UNCONFIGURED=true</code> exercise the no-key path without requiring a missing key in the test environment.</p>
<h3>2. Forced failure sequences for circuit breaker testing</h3>
<pre><code class="language-typescript">let forcedFailuresRemaining = env.LLM_STUB_FAIL_TIMES;

if (forcedFailuresRemaining &gt; 0) {
  forcedFailuresRemaining -= 1;
  return Promise.reject(new UpstreamUnavailableError('stub: forced upstream failure'));
}
</code></pre>
<p><code>LLM_STUB_FAIL_TIMES=3</code> forces three consecutive <code>UpstreamUnavailableError</code>s across the next three calls, then reverts to the happy path. This drives the <code>closed → open</code> transition in the circuit breaker without any network involvement.</p>
<p>The module-level counter (<code>forcedFailuresRemaining</code>) persists across calls within a test run, which is exactly what you need to test consecutive-failure transitions. Tests that need to reset it call <code>__resetStub(n)</code>:</p>
<pre><code class="language-typescript">export const __resetStub = (failTimes = env.LLM_STUB_FAIL_TIMES): void =&gt; {
  forcedFailuresRemaining = failTimes;
};
</code></pre>
<p>This is an explicitly test-only export. The naming convention (double underscore prefix) signals that it should never be called from production code.</p>
<h3>3. Filename-based document routing</h3>
<pre><code class="language-typescript">const filename = pdf?.filename ?? '';
if (filename === 'fail.pdf') {
  return Promise.reject(new UpstreamUnavailableError('stub: forced upstream failure (fail.pdf)'));
}
</code></pre>
<pre><code class="language-typescript">if (tier === 'gate') {
  const verdict = filename === 'reject.pdf' ? GATE_REJECT : GATE_HAPPY;
  return Promise.resolve(result(verdict, tier, code));
}
</code></pre>
<p>A PDF named <code>reject.pdf</code> triggers an invalid document verdict. A PDF named <code>fail.pdf</code> throws. Any other name gets the happy-path response. This makes test intent visible in the test file itself — the name of the PDF being uploaded describes the scenario being tested.</p>
<h3>4. The Kuda bug fixture</h3>
<p>This is the one I'm most proud of, because it came from a real production observation.</p>
<p>Kuda MFB bank statements produce credits with narrations like "Stac Intercontinental Ltd transfer" and "Abolarinwa Babafemi transfer." Both are income — regular client payments. But the word "transfer" appears in both, and the analysis model, if it applies the classification guidance too conservatively, tags both as <code>transfer</code> rather than <code>business</code>. Result: <code>grossAnnualKobo: 0</code> despite <code>inflows</code> summing to over ₦1.6M.</p>
<p>The stub captures this as a permanent fixture:</p>
<pre><code class="language-typescript">const ANALYSIS_ALL_TRANSFER = {
  inflows: [
    {
      date: '2026-04-24',
      description: 'Stac Intercontinental Ltd transfer',
      amountKobo: 100_000_000,
      classification: 'transfer',
    },
    {
      date: '2026-05-04',
      description: 'Abolarinwa Babafemi transfer',
      amountKobo: 60_000_000,
      classification: 'transfer',
    },
  ],
  grossAnnualKobo: 0,
};
</code></pre>
<p>Setting <code>LLM_STUB_ANALYSIS=all_transfer</code> in the test environment makes the stub return this fixture for every analysis call. The pipeline then hits the A2 guard:</p>
<pre><code class="language-typescript">const inflowsSumKobo = inflows.reduce((s, f) =&gt; s + f.amountKobo, 0);
const needsReview = grossAnnualKobo === 0 &amp;&amp; inflowsSumKobo &gt; 0;
</code></pre>
<p>And routes to <code>needs_review</code>. This edge case is now tested on every commit without ever needing the actual Kuda statement format or a live model call.</p>
<p>This is the real value of a steerable stub: production observations become permanent regression tests. The first time you see an edge case in prod, you add it to the stub. It never surprises you again.</p>
<h3>5. Non-conforming output for repair-retry testing</h3>
<pre><code class="language-typescript">if (env.LLM_STUB_CHAT === 'nonconforming') {
  return Promise.resolve(result({ wrong: 'shape', answer: '' }, tier, code));
}
</code></pre>
<p>This returns an object that will fail the Zod schema validation in <code>runStructured</code>. The test exercises the repair-retry path:</p>
<ol>
<li><p>First attempt: stub returns <code>{ wrong: 'shape', answer: '' }</code></p>
</li>
<li><p><code>schema.safeParse</code> fails → <code>LlmContractError</code></p>
</li>
<li><p><code>runStructured</code> logs a warning and retries</p>
</li>
<li><p>Second attempt: same stub response (env hasn't changed) → same failure</p>
</li>
<li><p><code>ProcessingError</code> thrown: "The AI could not produce a grounded answer"</p>
</li>
</ol>
<p>The test asserts that a <code>ProcessingError</code> is thrown and that the circuit breaker state did not change (a <code>LlmContractError</code> is not an outage — it should not count against the breaker). Both of these assertions validate production behaviour without calling OpenAI.</p>
<hr />
<h2>What the Shared Shell Actually Tests</h2>
<p>The key insight is that both transports run through <code>runStructured</code>. A test using the stub is not testing a simplified version of the system — it's running the full production logic path.</p>
<p>Here's the complete set of production components that are exercised by stub-mode tests:</p>
<p><strong>Circuit breaker</strong> — <code>breaker.run(() =&gt; transport(params))</code> runs for every call, real or stub. Failure sequences correctly drive state transitions.</p>
<p><strong>Audit writes</strong> — <code>llmAuditRepository.record(...)</code> is called after every transport invocation. Tests can assert that audit records were created with the expected <code>tier</code>, <code>model</code>, <code>circuitState</code>, and error fields.</p>
<p><strong>Schema validation</strong> — <code>schema.safeParse(result.parsed)</code> runs on every transport response. A stub that returns a wrong shape triggers the same <code>LlmContractError</code> path as a misbehaving real model.</p>
<p><strong>Repair retry</strong> — <code>LlmContractError</code> triggers one retry attempt. Tests with <code>LLM_STUB_CHAT=nonconforming</code> drive this path to completion and assert on the final error type.</p>
<p><strong>PII guarantee</strong> — The audit record stores <code>promptHash</code>, not the raw system or user prompt. This is verifiable in tests: assert that <code>llm_audit</code> records contain no bank statement content.</p>
<hr />
<h2>Testing the Tax Engine in Isolation</h2>
<p>The tax engine (<code>compareRegimes</code>, <code>computeFromGross</code>) is a pure TypeScript function with no I/O. It takes income figures and profile parameters. It returns a full computation. It has no retries, no timeouts, no external calls.</p>
<p>Pure functions should be tested exhaustively with explicit input/output pairs. No stubs needed, no mocks, no setup:</p>
<pre><code class="language-typescript">// These are representative — a real suite would cover every NTA 2025 band boundary

test('salary earner at ₦2.4M annual gross computes correct liability', () =&gt; {
  const result = computeFromGross('salary_earner', 240_000_000); // 240M kobo = ₦2.4M
  expect(result.newRegime.taxPayableKobo).toBe(/* NTA 2025 Fourth Schedule calculation */);
  expect(result.recommendation).toBe('new_regime');
});

test('gross of zero produces zero liability', () =&gt; {
  const result = computeFromGross('salary_earner', 0);
  expect(result.newRegime.taxPayableKobo).toBe(0);
  expect(result.oldRegime.taxPayableKobo).toBe(0);
});
</code></pre>
<p>These tests run in milliseconds and give you complete confidence in the computation logic. They are the tests that matter most — because if the tax engine is wrong, users file incorrect returns. The LLM stub tests confirm the pipeline plumbing. The tax engine tests confirm the law is correctly implemented.</p>
<hr />
<h2>Testing the State Machine</h2>
<p>The <code>taxProcessRepository</code> persists pipeline state transitions. The sequence is:</p>
<pre><code class="language-plaintext">created → validating → analyzing → ready
                    ↘           ↘ needs_review
                     failed
</code></pre>
<p>These transitions are driven by <code>taxProcessRepository.advance(code, newState, data)</code>. With an in-memory MongoDB instance (or a real test database), the state machine can be driven entirely through the stub transport:</p>
<ul>
<li><p><code>reject.pdf</code> → gate fails → advance to <code>failed</code></p>
</li>
<li><p><code>fail.pdf</code> → gate throws → advance to <code>failed</code> with unavailability reason</p>
</li>
<li><p><code>LLM_STUB_ANALYSIS=all_transfer</code> → analysis produces zero gross → advance to <code>needs_review</code></p>
</li>
<li><p>Default happy path → advance to <code>ready</code> with computed tax figures</p>
</li>
</ul>
<p>No OpenAI calls. Full state machine coverage. Each scenario maps directly to a stub steering configuration.</p>
<hr />
<h2>Trade-offs</h2>
<p><strong>The stub must be maintained.</strong> When the real model's output schema changes, the stub must be updated to match. A stub that diverges from the real transport's contract gives you green tests against a broken production path — which is the original problem in a different form.</p>
<p>The mitigation: the <code>Transport</code> type acts as a contract. The stub implements <code>Transport</code>. If the <code>TransportResult</code> type changes, TypeScript will catch stub divergence at compile time for structural changes. Semantic drift (the model now returns a different classification vocabulary, for example) requires human attention — it's the kind of thing that should be caught by integration tests that run against real models periodically, not on every commit.</p>
<p><strong>The fixture set is a maintenance surface.</strong> <code>ANALYSIS_HAPPY</code>, <code>ANALYSIS_ALL_TRANSFER</code>, <code>GATE_HAPPY</code>, <code>GATE_REJECT</code>, <code>CHAT_ANSWER</code>, <code>CHAT_REFUSE</code> — each fixture is a claim about what the real model would return. As the model's system prompts evolve, the fixtures can fall out of sync.</p>
<p>The practice I follow: when any system prompt changes, run the real model against a sample document and compare the output structure to the existing fixture. If the structure matches, the fixture is still valid. If not, update it. This is one manual step per prompt change, not per commit.</p>
<p><strong>The stub cannot test model quality.</strong> The stub tests that the pipeline handles the model's output correctly. It says nothing about whether the model produces good output. Classification accuracy, income estimation quality, and chat answer relevance are not tested by stubs — they require human evaluation or a separate LLM-as-judge harness that runs against the real model on a schedule, not on every commit.</p>
<hr />
<h2>The Rule</h2>
<p>Isolate non-determinism at the transport boundary. Define a <code>Transport</code> interface. Make the stub implement it exactly. Steer the stub via environment variables, not hardcoded single-case fixtures. The stub should be able to reproduce every production edge case you've ever seen — including the Kuda mis-classification, the forged document, the 503 during peak traffic, and the model that returns empty output on an otherwise valid prompt.</p>
<p>When a new edge case appears in production, the first commit is always the same two things: the fix, and the stub configuration that reproduces the scenario it came from.</p>
<p><strong>The stub is not a test convenience. It's a production incident archive.</strong></p>
]]></content:encoded></item><item><title><![CDATA[How I Built "Wordshot" a Word Game That Scales to 10 Million Words (And the Architecture Decisions That Made It Possible)]]></title><description><![CDATA[Most word games have a few thousand words. I collected 10.2 million. What started as "let me scrape some names" turned into a three-week hardwork that forced every architectural decision I made afterward.
This is the story of building WordShot, a rea...]]></description><link>https://crackedchefs.devferanmi.xyz/how-i-built-wordshot-a-word-game-that-scales-to-10-million-words-and-the-architecture-decisions-that-made-it-possible</link><guid isPermaLink="true">https://crackedchefs.devferanmi.xyz/how-i-built-wordshot-a-word-game-that-scales-to-10-million-words-and-the-architecture-decisions-that-made-it-possible</guid><category><![CDATA[TypeScript]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[React]]></category><category><![CDATA[Express]]></category><category><![CDATA[Game Development]]></category><dc:creator><![CDATA[Oluwaferanmi Adeniji]]></dc:creator><pubDate>Wed, 31 Dec 2025 10:52:42 GMT</pubDate><content:encoded><![CDATA[<p>Most word games have a few thousand words. I collected 10.2 million. What started as "let me scrape some names" turned into a three-week hardwork that forced every architectural decision I made afterward.</p>
<p>This is the story of building WordShot, a real-time multiplayer word game where players race against time to answer categories using words that start with a randomly selected letter. Think Scattergories, but with 10.2 million words in the database, real-time WebSocket synchronization for 2-8 players, and architecture decisions that seemed obvious until they weren't..</p>
<p>It started simple enough. A word game needs words. Categories like Animals, Cities, Food, Names, the basics. I figured I'd grab some public domain word lists, maybe scrape Wikipedia for place names, and call it a day. A weekend project, tops.</p>
<p>Then I remembered: I'm Nigerian. This game would have Nigerian users. That means Yoruba names like "Ọmọkehinde" and "Adébáyọ̀" should be valid. Igbo names like "Chukwuemeka" and "Nneka" too. I couldn't just use American name databases.</p>
<p>So I started collecting.</p>
<h3 id="heading-the-bible-problem">The Bible Problem</h3>
<p>The game has a "Bible" category for biblical references. Simple, right? Just grab some names from the King James Bible and move on.</p>
<p>Except... how many biblical names are there actually?</p>
<p>First attempt: YouVersion API. Found about 800 common names (Abraham, Moses, David). Felt good about myself.</p>
<p>Second attempt: Seminary documents. Found open-source theological databases. YAML files with genealogies and cross-references. Another 1,200 names (Zerubbabel, Mahershalalhashbaz, yes these are real).</p>
<p>Third attempt: Bible JSON projects on GitHub. Multiple repos with structured Bible data. Different translations had different transliterations. Another 1,000 unique spellings.</p>
<p>I wrote a script that parsed all three sources. Recursively extracted names from nested structures. Deduplicated. Normalized Unicode (Hebrew names have diacritics). Final count: about 3,000 unique biblical names.</p>
<h3 id="heading-the-yoruba-names-challenge">The Yoruba Names Challenge</h3>
<p>This was harder. There's no "official Yoruba name database." It's an oral tradition, passed down through families. Different regions spell names differently. The romanization isn't standardized.</p>
<p>I needed AI.</p>
<p>I built an iterative script that prompted Claude, OpenAI, and Gemini in rounds. The approach: generate letter combinations (aa, ab, ad, ak...) and ask each AI provider for authentic Yoruba names starting with those letters.</p>
<p>Why this worked:</p>
<ul>
<li><p>Each AI provider has different training data</p>
</li>
<li><p>Rotating providers gave me diverse results</p>
</li>
<li><p>Letter combos forced systematic coverage</p>
</li>
<li><p>Incremental saves meant I didn't lose progress if something failed</p>
</li>
</ul>
<p>I let this run overnight. Next morning: about 12,500 unique Yoruba names.</p>
<p>But I wasn't done. I repeated the process for Igbo names, Hausa names, Swahili names. By the end, I had about 105,000 African names that no American word game would ever have.</p>
<h3 id="heading-the-dictionary-breakdown">The Dictionary Breakdown</h3>
<p>I found public domain dictionaries online. Massive 50MB text files with words and definitions. Hundreds of thousands of words. But they weren't categorized. I needed to know: is "aardvark" an animal? Is "abacus" a thing?</p>
<p>I wrote a parser that used definition keywords to categorize. Animal keywords: mammal, bird, reptile, fish, insect. Food keywords: edible, fruit, vegetable, dish. Place keywords: city, town, country, location.</p>
<p>This categorization wasn't perfect. "Apple" could be a food or a company. Context matters. But for a first pass, keyword matching got me about 40,000 categorized words.</p>
<h3 id="heading-the-maps-scraping-mission">The Maps Scraping Mission</h3>
<p>For the "Place" and "City" categories, I needed real locations. Not just "Paris" and "London", I wanted "Ogbomoso" and "Enugu" and "Zanzibar”, and even niche places like “Ejigbo”, “Iwo”, or “Ikoyi” etc.</p>
<p>I scraped Google Maps (Places API, 1000 requests/day free tier), Apple Maps (browser automation with Puppeteer), and Wikipedia ("List of cities in [country]" pages).</p>
<p>I ran this for every country on Earth. Took 2 days because rate limiting was brutal. Final count: about 115,000 place names.</p>
<h3 id="heading-the-pipeline">The Pipeline</h3>
<p>Now I had data from multiple sources:</p>
<ul>
<li><p>Bible: 3,000 names</p>
</li>
<li><p>African names: 8,000</p>
</li>
<li><p>Dictionary: 40,000 words</p>
</li>
<li><p>Maps: 15,000 places</p>
</li>
</ul>
<p>But the formats were all different. Some had uppercase, some lowercase. Some had Unicode, some ASCII. Some had duplicates across sources.</p>
<p>I needed a unified pipeline. The logic: normalize Unicode, lowercase everything, deduplicate by <code>word:category</code> key, validate (minimum length, no special chars), extract first letter, find aliases.</p>
<p>I ran this pipeline. Deduped everything. Validated each entry. Final database size: 10.2 million words across 13 categories.</p>
<h3 id="heading-the-alias-problem-still-ongoing">The Alias Problem (Still Ongoing)</h3>
<p>Even with 10.2 million words, there's a problem: spelling variations. "Grey" vs "Gray". "Judgement" vs "Judgment". "Theatre" vs "Theater". UK vs US vs EU spellings.</p>
<p>A player types "Grey" but the database has "Gray." They score zero. That's frustrating.</p>
<p>I built a background cron job that runs nightly. It evaluates words per night. For each word: ask AI providers for aliases, scrape dictionary sites for alternate spellings, use logic for UK/US/EU variations (our vs or, re vs er, ise vs ize).</p>
<p>This job processes words per time. The most common words get evaluated first. The long tail can wait.</p>
<h3 id="heading-the-feedback-loop">The Feedback Loop</h3>
<p>There's another spawn job that runs in the background. When a player submits an answer that's marked wrong (not in database), the system queues it for re-evaluation.</p>
<p>The process: ask AI if the word is valid for that category, web scraping for verification, combine signals. If valid, add to database and credit the user (future feature).</p>
<p>This means the database grows over time. Players teach the system. If 10 players submit "Oko" for "City" and it's actually a valid Nigerian city, the system learns. Next player who uses "Oko" gets points.</p>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p>The word collection wasn't just data entry. It forced every architectural decision I made afterward:</p>
<ol>
<li><p>10.2 million records made database indexes mandatory (queries were 150ms without them)</p>
</li>
<li><p>Progressive evaluation made background jobs non-blocking (can't freeze gameplay)</p>
</li>
<li><p>Spelling variations made the alias system complex (can't just check exact matches)</p>
</li>
<li><p>Multiple sources made the data pipeline critical (can't manually merge formats)</p>
</li>
<li><p>Continuous growth made the database design support writes during gameplay</p>
</li>
</ol>
<p>If I'd stopped at 100,000 words, I wouldn't have learned these lessons. The scale forced me to build better systems.</p>
<p>Three weeks of word collection. Ten million words. Every architecture decision afterward was shaped by this foundation.</p>
<h2 id="heading-feature-sliced-design-the-decision-i-got-right-from-day-one">Feature-Sliced Design (The Decision I Got Right From Day One)</h2>
<p>I've built systems that turned into spaghetti. I wasn't doing that again.</p>
<p>Before writing a single line of game logic, I knew three things:</p>
<ol>
<li><p>The game would have multiple modes (single-player, multiplayer, demo)</p>
</li>
<li><p>Features would grow independently</p>
</li>
<li><p>Folder-by-type (<code>/components</code>, <code>/hooks</code>, <code>/utils</code>) was a trap I'd fallen into before</p>
</li>
</ol>
<p>I chose Feature-Sliced Design from the start. Not because it was trendy, but because I'd seen what happens without it.</p>
<h3 id="heading-the-traditional-hell">The Traditional Hell</h3>
<p>On one of my old project, the structure looked like this: everything in <code>/components</code>, <code>/hooks</code>, <code>/utils</code>, <code>/types</code>. 47 components. 23 hooks. 31 utility files. 18 type files.</p>
<p>What happened:</p>
<ul>
<li><p>Want to understand multiplayer? Grep across 4 folders</p>
</li>
<li><p>Change one feature? Touch files in every folder</p>
</li>
<li><p>Onboard a new dev? "Good luck understanding how this all connects"</p>
</li>
<li><p>Remove a feature? Hope you found every related file</p>
</li>
</ul>
<p>It was chaos. Every feature touched every folder. No clear boundaries.</p>
<h3 id="heading-the-fsd-approach">The FSD Approach</h3>
<p>This time, I organized by feature. Each feature owns its complete stack: API calls, state management, UI components, types, routing.</p>
<p>The structure:</p>
<pre><code class="lang-plaintext">src/
├── features/
│   ├── game/           (single-player)
│   ├── multiplayer/    (multiplayer)
│   └── demo/           (walkthrough)
└── shared/             (cross-feature)
    ├── services/
    ├── hooks/
    └── ui/
</code></pre>
<p>What this gives me:</p>
<p><strong>Clear feature boundaries.</strong> Want to understand single-player? Everything is in <code>features/game/</code>. API calls, state management, UI components, types, routing, all in one place.</p>
<p><strong>Parallel development.</strong> I built the demo mode while multiplayer was still in progress. Zero conflicts. They don't share code except for <code>shared/</code>.</p>
<p><strong>Easy deletion.</strong> When I considered removing the demo feature, I looked at the <code>features/demo/</code> folder. That's it. No hunting across the codebase.</p>
<p><strong>Feature-level testing.</strong> Each feature can be tested in isolation. Mock the API layer, test the provider, verify the UI.</p>
<h3 id="heading-the-demo-mode-that-took-2-days">The Demo Mode That Took 2 Days</h3>
<p>The clearest proof that FSD worked: I built the demo mode in 2 days.</p>
<p>What is demo mode? Interactive walkthrough for first-time users. Shows how the game works step-by-step. No API calls, no real gameplay, just guided UI.</p>
<p>Why it was fast: Created <code>features/demo/</code> folder, copied UI components from <code>features/game/</code>, wrote a simple state machine for the walkthrough, hooked it into routing. Done.</p>
<p>No refactoring. No "how do I isolate this from the main game?" questions. FSD already had the answer: it's a separate feature.</p>
<p>If the codebase was folder-by-type, I'd still be untangling dependencies.</p>
<h3 id="heading-the-rule-i-follow">The Rule I Follow</h3>
<p>Not everything goes in <code>shared/</code>. There's a rule: if code is used by 2+ features, move it to <code>shared/</code>. Otherwise, keep it in the feature.</p>
<p>Examples moved to shared: Button component (used everywhere), sound service (used everywhere), cache service (used by game and multiplayer).</p>
<p>Examples that stayed in features: roulette screen (only single-player), WebSocket provider (only multiplayer), role encoding (multiplayer-specific).</p>
<p>This prevents premature abstraction. Code starts in a feature. If another feature needs it, then we move it to <code>shared/</code>.</p>
<h2 id="heading-the-minimal-state-philosophy">The Minimal State Philosophy</h2>
<p>React developers love state. I learned to hate it.</p>
<p>Not because state is bad. But because unnecessary state is a bug waiting to happen.</p>
<h3 id="heading-the-previous-project-where-state-killed-me">The Previous Project Where State Killed Me</h3>
<p>On my last project, we used Redux. Every feature dumped its state into the global store. Why? "Because we might need it elsewhere."</p>
<p>What happened: 37 action creators, 28 reducers, selectors everywhere. No one knew what was in the store at any given time. Debugging meant logging the entire state tree. Updates triggered re-renders across unrelated components.</p>
<p>The final straw: A dev updated <code>game.currentRound</code> and accidentally broke the notification badge because the badge subscribed to the entire <code>game</code> object, not just <code>game.roundsComplete</code>.</p>
<p>I swore off Redux after that project.</p>
<h3 id="heading-the-102m-words-problem">The 10.2M Words Problem</h3>
<p>With 10.2 million words in the database, I became paranoid about state.</p>
<p>Question: Should I cache the entire word database in Redux?</p>
<p>Math: 10.2M words × 100 bytes each = 1GB. JavaScript heap limit: 1.5GB. Answer: Hell no.</p>
<p>Question: Should I cache the current round's valid words in state?</p>
<p>Math: Player sees 3-5 categories per round. Each category has about 5,000 valid words. 5 categories × 5,000 words × 100 bytes = 2.5MB. Answer: Maybe, but probably overkill.</p>
<p>Question: Should I cache the player's answers in state?</p>
<p>Math: Player submits 3-5 answers per round. 10 rounds max = 50 answers total. 50 answers × 50 bytes = 2.5KB. Answer: Yes, this makes sense.</p>
<p>The scale forced me to be ruthless about what deserved to be in state.</p>
<h3 id="heading-my-decision-matrix">My Decision Matrix</h3>
<p>I made a decision matrix before writing any state management code:</p>
<p>For this project:</p>
<ul>
<li><p>2 features share state (Game and Multiplayer)</p>
</li>
<li><p>State transitions are simple (Player answers, Validation, Results)</p>
</li>
<li><p>No time-travel debugging needed</p>
</li>
<li><p>No middleware needed</p>
</li>
</ul>
<p>Verdict: Context API wins.</p>
<h3 id="heading-the-three-layer-state-architecture">The Three-Layer State Architecture</h3>
<p>Layer 1: Global State (Minimal). App-level state: error boundary state, sound preference. That's it.</p>
<p>Layer 2: Feature State (Context API). Each feature has its own provider. GameProvider for single-player. MultiplayerProvider for multiplayer.</p>
<p>Layer 3: Component State (Local UI). Everything else is component-local. Input values, modal visibility, selected tabs. This state doesn't need to be global. It's purely UI concern.</p>
<h3 id="heading-the-multiplayer-multi-provider-hierarchy">The Multiplayer Multi-Provider Hierarchy</h3>
<p>Multiplayer has two providers: WebSocketProvider (connection layer) and MultiplayerProvider (game state layer).</p>
<p>Why two providers?</p>
<p>WebSocketProvider handles low-level connection: socket instance, connection status, reconnection attempts, send message wrapper.</p>
<p>MultiplayerProvider handles game logic: room data, player list, game phase, actions (create room, join room, start game).</p>
<p>Why separate? WebSocket can reconnect without resetting game state. Game state can be manipulated independently of connection status. Testing: Mock WebSocket provider, test game logic in isolation.</p>
<h3 id="heading-what-i-keep-in-state-vs-what-i-dont">What I Keep in State vs What I Don't</h3>
<p>What's in state: Current game ID, current round number, player answers, multiplayer room data, WebSocket connection status.</p>
<p>What's NOT in state: Word database (way too big), validation results (fetched once from API), game history (stored in localStorage), sound effects (singleton service), UI animations (Framer Motion).</p>
<h3 id="heading-the-rule-derive-state-when-possible">The Rule: Derive State When Possible</h3>
<p>If data can be calculated from existing state, don't store it separately.</p>
<p>Bad: Store <code>currentRound</code>, <code>totalRounds</code>, and <code>isLastRound</code>. Now you have to keep <code>isLastRound</code> in sync.</p>
<p>Good: Store <code>currentRound</code> and <code>totalRounds</code>. Calculate <code>isLastRound = currentRound === totalRounds</code>.</p>
<p>One source of truth. No sync bugs.</p>
<h2 id="heading-the-serviceresult-pattern-why-i-stopped-using-exceptions">The ServiceResult Pattern (Why I Stopped Using Exceptions)</h2>
<p>Exceptions made sense until I had to debug them in production at 2 AM.</p>
<p>The problem isn't exceptions themselves. It's that exceptions make your code lie. A function signature says it returns <code>User</code>, but secretly it might throw <code>UserNotFoundError</code> or <code>DatabaseConnectionError</code> or <code>ValidationError</code>. You don't know until it happens.</p>
<h3 id="heading-the-production-bug">The Production Bug</h3>
<p>Backend code, Node.js + Express. User starts a game. Backend throws <code>LetterSelectionError</code> (couldn't find enough valid letters). Caught by the generic catch block. Returns 500 error. Frontend shows "Internal Server Error."</p>
<p>But it's not a server error. It's a validation error. The user selected incompatible categories. But the generic exception handling made everything a 500.</p>
<p>I had to dig through logs to find the real error. 2 AM. Production down.</p>
<h3 id="heading-the-problem-with-exceptions">The Problem With Exceptions</h3>
<p>Hidden control flow. Non-local reasoning. Lost type safety. Boilerplate everywhere.</p>
<p>To understand what a function can fail with, you have to read its entire implementation and every function it calls. TypeScript can't tell you what exceptions a function throws. The type system is blind to error paths.</p>
<h3 id="heading-the-serviceresult-pattern">The ServiceResult Pattern</h3>
<p>Every service method returns <code>ServiceResult&lt;T&gt;</code>. It's a discriminated union: either <code>{ success: true, data: T }</code> or <code>{ success: false, error: string }</code>.</p>
<p>Every caller must check <code>.success</code>. TypeScript enforces this. Try to access <code>.data</code> without checking? Compiler error.</p>
<p>Why this works at scale: With 10.2 million words, errors become common. Player types a word not in database. Player selects rare letter with not enough categories. Player submits too fast and hits rate limit. These aren't exceptional. They're normal operation.</p>
<p>With exceptions, you have to know every possible exception. Miss one, app crashes.</p>
<p>With ServiceResult, all error paths funnel through <code>!result.success</code>. One check. No surprises.</p>
<h3 id="heading-type-safety-across-50-websocket-events">Type Safety Across 50+ WebSocket Events</h3>
<p>This pattern extends to WebSocket. Room created response: either <code>{ success: true, data: Room }</code> or <code>{ success: false, message: string }</code>.</p>
<p>Benefits: Discriminated unions enforce success checks. TypeScript provides autocomplete for both paths. No silent failures.</p>
<h3 id="heading-the-pattern-that-looked-verbose-but-saved-me">The Pattern That Looked Verbose But Saved Me</h3>
<p>Yes, ServiceResult adds lines of code. Before was 1 line. After is 4 lines.</p>
<p>But here's what I gained:</p>
<p>Every error path is visible. No hidden throws. Compiler enforces error handling. Errors are data, not control flow. Consistent response format across all API endpoints.</p>
<p>Measured impact:</p>
<p>Before ServiceResult: 23 unhandled exceptions in production (first month). 7 of those were user-facing crashes. Average debug time: 45 minutes.</p>
<p>After ServiceResult: 0 unhandled exceptions. 0 user-facing crashes from error handling. Average debug time: 5 minutes.</p>
<h2 id="heading-database-design-the-denormalization-decision">Database Design: The Denormalization Decision</h2>
<p>Normalizing 10.2 million words seemed right. It was 98% slower.</p>
<p>This is the section where I learned that database theory and database reality are different things.</p>
<h3 id="heading-the-naive-approach">The Naive Approach</h3>
<p>Initial schema: just <code>word</code>, <code>category</code>, and <code>aliases</code>. No <code>startsWith</code> field. Calculate it on-the-fly with MongoDB's <code>$substr</code> operator.</p>
<p>Query to validate "Apple" in "Food" starting with "A": use <code>$expr</code> with <code>$substr</code> to check first character.</p>
<p>Performance: Full collection scan (COLLSCAN). 10.2 million documents examined. Query time: about 400ms.</p>
<p>Why so slow? MongoDB can't index computed fields. The <code>$substr</code> expression runs on every document. No way to optimize it.</p>
<h3 id="heading-the-denormalization-decision">The Denormalization Decision</h3>
<p>I added a <code>startsWith</code> field. Just one extra byte per document. Store the first letter explicitly.</p>
<p>Query becomes simpler: match on <code>word</code>, <code>category</code>, and <code>startsWith</code>.</p>
<p>Performance with compound index: Index scan (IXSCAN). About 89 documents examined (only words starting with 'a' in 'food'). Query time: less than 5ms.</p>
<p>Improvement: 98.75% faster.</p>
<h3 id="heading-the-trade-off-analysis">The Trade-Off Analysis</h3>
<p>Cost of denormalization: Extra field, 1 byte per document. 10.2M documents × 1 byte = 10MB. Disk space: Negligible.</p>
<p>Maintenance cost: One pre-save hook to keep <code>startsWith</code> in sync. 3 lines of code.</p>
<p>Benefit: 98% faster queries. No full collection scans. Scales to billions of words.</p>
<p>Verdict: Worth it.</p>
<h3 id="heading-compound-index-strategy">Compound Index Strategy</h3>
<p>I needed two indexes:</p>
<p>Index 1: <code>{ startsWith: 1, category: 1 }</code> for answer validation (most common query).</p>
<p>Index 2: <code>{ category: 1, startsWith: 1 }</code> for cache building (startup).</p>
<p>Why both orders? MongoDB can only use an index if the query matches the prefix of the index. Without both indexes, one query pattern would be slow.</p>
<p>Measured impact: Validation went from 400ms to 5ms. Cache build went from 800ms to 8ms.</p>
<h3 id="heading-embedded-vs-referenced-documents">Embedded vs Referenced Documents</h3>
<p>Another decision: How to store game sessions?</p>
<p>Option 1: Embedded. Everything in one document. Players, rounds, results, all nested.</p>
<p>Option 2: Referenced. Separate collections. Game session has player IDs pointing to Players collection, round IDs pointing to Rounds collection.</p>
<p>Decision matrix: Embedded wins on query complexity (1 query vs 3+), atomicity (single doc update vs multi-collection transaction), and game summary speed (10ms vs 100ms).</p>
<p>Why embedded won: Self-contained games (4-10 rounds, never more). Document size well below 16MB limit. Single query faster. Atomic updates prevent race conditions. Simpler code.</p>
<p>Document size analysis: Worst case 8 players, 10 rounds, 5 categories per round. About 63KB total. Well within 16MB limit.</p>
<h2 id="heading-the-background-jobs-that-never-stop">The Background Jobs That Never Stop</h2>
<p>The word collection ended. The word refinement never will.</p>
<h3 id="heading-the-alias-evaluator">The Alias Evaluator</h3>
<p>Even with 10.2 million words, the alias problem persists. "Grey" vs "Gray". "Judgement" vs "Judgment". UK vs US vs EU spellings.</p>
<p>I built a background cron job that runs nightly. It evaluates 1,000 words per night. For each word: check AI providers for aliases, web scraping for alternate spellings, logic for spelling variations (our/or, re/er, ise/ize).</p>
<p>The job processes 1,000 words per night. At that rate, it'll take 27 years to evaluate all 10.2 million words. But it's fine. The most common words get evaluated first. The long tail can wait.</p>
<h3 id="heading-the-feedback-loop-1">The Feedback Loop</h3>
<p>There's another spawn job. When a player submits an answer that's marked wrong (not in database), the system queues it for re-evaluation.</p>
<p>Process: Ask AI if the word is valid for that category. Web scraping for verification. Combine signals. If valid, add to database.</p>
<p>This means the database grows over time. Players teach the system. If 10 players submit "Oko" for "City" and it's actually a valid Nigerian city, the system learns it. Next player who uses "Oko" gets points.</p>
<h3 id="heading-the-continuous-improvement-pipeline">The Continuous Improvement Pipeline</h3>
<p>These background jobs aren't just cleanup. They're the system learning from real usage. Every failed answer is a signal. Every alias discovered is an improvement. The database isn't static. It evolves.</p>
<p>This is only possible because the architecture supports it. ServiceResult pattern makes errors data. Background jobs are non-blocking. Database design supports concurrent writes during gameplay.</p>
<h2 id="heading-the-patterns-that-scaled-and-the-ones-i-refactored">The Patterns That Scaled (And the Ones I Refactored)</h2>
<p>Not everything I built on day one survived contact with 10.2 million words.</p>
<h3 id="heading-what-held-up">What Held Up</h3>
<p>Feature-Sliced Design: Never had to refactor folder structure. Adding multiplayer didn't break single-player. Demo mode took 2 days.</p>
<p>ServiceResult Pattern: Zero unhandled exceptions in production. Every error path is visible and handled.</p>
<p>Minimal State Philosophy: Never hit memory limits. State management stayed simple even as features grew.</p>
<p>Pre-Computed Caches: Game starts in less than 10ms. Letter selection has 0% failure rate. Cache build takes 3 seconds on startup, saves hours of cumulative query time.</p>
<h3 id="heading-what-i-refactored">What I Refactored</h3>
<p>Initial letter selection: Had 2% failure rate. Users couldn't start games. Fixed with pre-validation during selection. Now 0% failures.</p>
<p>Validation flow: Was 150ms per query. Users waited too long. Added compound indexes and two-tier caching. Now less than 5ms.</p>
<p>Room cleanup: Memory leaked 400% daily. Abandoned rooms stayed in cache forever. Added periodic cleanup job and TTL. Now memory stays stable.</p>
<h3 id="heading-what-id-do-differently">What I'd Do Differently</h3>
<p>Start with Redis instead of in-memory cache. In-memory cache means single-server deployment. Can't scale horizontally. Redis would allow multi-server setup.</p>
<p>Build admin panel for word moderation earlier. Right now, adding/removing words requires database access. Admin panel would let non-technical team members curate the database.</p>
<p>Test on Nigerian mobile networks from day one. I built on fast Wi-Fi. Real users on MTN 3G had different experience. The three-layer reconnection strategy came from this pain.</p>
<h2 id="heading-the-numbers">The Numbers</h2>
<p>Let's be honest about what this architecture achieved.</p>
<p>Performance gains:</p>
<ul>
<li><p>Game start: 300ms to less than 10ms (97% faster)</p>
</li>
<li><p>Answer validation: 150ms to less than 5ms (97% faster)</p>
</li>
<li><p>API throughput: 5x increase (400% improvement)</p>
</li>
<li><p>Memory usage: 35% reduction</p>
</li>
<li><p>Game failures: 2% to 0% (100% elimination)</p>
</li>
</ul>
<p>Code quality:</p>
<ul>
<li><p>TypeScript coverage: 100% (zero JavaScript files)</p>
</li>
<li><p>Type safety: Zero <code>any</code> types in source code</p>
</li>
<li><p>Feature isolation: 3 independent feature folders</p>
</li>
<li><p>State layers: 3 distinct layers (global, feature, component)</p>
</li>
</ul>
<p>User experience:</p>
<ul>
<li><p>Reconnection success rate: 98% on mobile</p>
</li>
<li><p>Session recovery: 95% (5% edge cases like cleared storage)</p>
</li>
<li><p>Error rate: Less than 1% of game sessions encounter errors</p>
</li>
</ul>
<h2 id="heading-the-honest-conclusion">The Honest Conclusion</h2>
<p>10.2 million words taught me something: scale isn't just about performance. It's about architecture that doesn't collapse when your database grows 100x larger than you planned. It's about minimal state when your data is massive. It's about patterns that assume things will break, so they're built to handle it.</p>
<p>I didn't plan to collect 10.2 million words. But the architecture decisions I made, Feature-Sliced Design, minimal state, ServiceResult pattern, database denormalization, they're the reason the game still works when a player types "Ọmọkehinde" and the system finds it in 4ms, validates it against UK/US/EU spellings, checks aliases, and returns whether it's rare or common.</p>
<p>The word collection was a three-week obsession. The architecture is the reason it didn't become a three-month refactor.</p>
<p>That's the real lesson: Good architecture isn't about planning for scale. It's about making decisions that work at small scale and don't break at large scale. Feature-Sliced Design works with 3 features or 30. ServiceResult works with 10 errors or 10,000. Minimal state works with 100KB of data or 1GB.</p>
<p>The patterns that scale are the patterns that start simple and stay simple as complexity grows. Not because they're clever, but because they refuse to be clever. They just work.</p>
<hr />
<p><strong>Tech Stack:</strong></p>
<ul>
<li><p>Frontend: React 18, TypeScript 5.6, Vite 6, Tailwind CSS</p>
</li>
<li><p>Backend: Node.js 18, Express, TypeScript</p>
</li>
<li><p>Database: MongoDB with Mongoose</p>
</li>
<li><p>Real-Time: <a target="_blank" href="http://Socket.IO">Socket.IO</a></p>
</li>
<li><p>Performance: NodeCache, compound indexes, write-behind caching</p>
</li>
</ul>
<p><strong>Metrics:</strong></p>
<ul>
<li><p>10.2 million words across 13 categories</p>
</li>
<li><p>97% performance improvement (300ms to 10ms)</p>
</li>
<li><p>98% session recovery rate on mobile</p>
</li>
<li><p>0% game initialization failures</p>
</li>
<li><p>15,000 lines of code, 3 months part-time</p>
</li>
</ul>
<p>The game works. The architecture held up. The words keep growing. And I learned that sometimes the best architecture decision is the one that lets you obsess over word collection for three weeks without breaking everything else.</p>
]]></content:encoded></item><item><title><![CDATA[ Grounding AI in High-Stakes Domains: When the LLM Must Never Produce the Number]]></title><description><![CDATA[A few months ago I shipped two products within weeks of each other. One computes your Nigerian income tax from a bank statement. The other decides whether a small business gets a BNPL loan. Different ]]></description><link>https://crackedchefs.devferanmi.xyz/grounding-ai-in-high-stakes-domains-when-the-llm-must-never-produce-the-number</link><guid isPermaLink="true">https://crackedchefs.devferanmi.xyz/grounding-ai-in-high-stakes-domains-when-the-llm-must-never-produce-the-number</guid><category><![CDATA[AI]]></category><category><![CDATA[research]]></category><category><![CDATA[research paper writing]]></category><dc:creator><![CDATA[Oluwaferanmi Adeniji]]></dc:creator><pubDate>Thu, 25 Dec 2025 08:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5d5009ed2db7c7fb3cd7cf28/9e24adfb-02a9-4c17-be12-7ed794317a08.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A few months ago I shipped two products within weeks of each other. One computes your Nigerian income tax from a bank statement. The other decides whether a small business gets a BNPL loan. Different domains, different users, different stakes — but the backend architecture converged on the same rule: <strong>the language model reads the document; deterministic code produces every number that matters.</strong></p>
<p>This isn't a style preference. It's the invariant the entire design is built around. Understanding why it's necessary, where the boundary sits, and what breaks when you cross it is what this article is about.</p>
<p>Let's get cracking,</p>
<hr />
<h2>The Problem with Trusting the Number</h2>
<p>Language models are remarkably good at reading. They can parse a scanned bank statement in six different formats, figure out that "SALARY JAN — STAC INTERCONTINENTAL" is employment income and "REVERSAL — INSUFFICIENT FUNDS" is a bounce, and produce structured output from unstructured chaos. This is genuinely hard, and they do it well.</p>
<p>They are not reliable calculators under legal constraints.</p>
<p>The failure mode isn't dramatic. The model doesn't say "I cannot compute tax" and refuse. It produces a number — confidently, fluently, with the same tone it uses when it's completely correct. The number might be ₦180,000 when the NTA 2025 Fourth Schedule says ₦240,000. The trust score might be 78 when the real affordability math says 41. There's no error message. There's no <code>undefined</code>. Just a wrong number, presented as fact.</p>
<p>And then someone acts on it.</p>
<p>A business owner is approved for a loan they cannot afford. A freelancer files a tax return with the wrong liability. The failure is silent, downstream, and by the time it surfaces, the model has been called ten thousand more times with the same architecture.</p>
<p>This is the problem. And the solution is not better prompting.</p>
<hr />
<h2>The Architectural Invariant</h2>
<p>The invariant I settled on across both TaxLens and TrustRail:</p>
<blockquote>
<p><strong>An LLM may extract, classify, and validate. It may never calculate, decide, or produce a figure the user will act on.</strong></p>
</blockquote>
<p>Three permitted operations. Three forbidden ones. The boundary between them is the line between the LLM doing what it's good at and the LLM being asked to do something it will eventually get wrong in a way you can't predict.</p>
<p>Let's walk through exactly how this plays out in both systems.</p>
<hr />
<h2>TaxLens: The Two-Tier Pipeline</h2>
<p>TaxLens processes a bank statement PDF to estimate Nigerian personal income tax under the NTA 2025. The user uploads a PDF. A few seconds later, they see their estimated tax liability, the applicable bands, and the inflows the system counted as income.</p>
<p>The pipeline has three tiers. Only the first two involve a language model.</p>
<h3>Tier 1: The Gate (cheap model)</h3>
<p>The gate model receives the PDF and answers exactly one question:</p>
<pre><code class="language-typescript">const GateVerdictSchema = z.object({
  valid: z.boolean(),
  bankName: z.string(),
  monthsCovered: z.number().int().nonnegative(),
  reason: z.string(),
});
</code></pre>
<p>Four fields. A boolean, a bank name, a month count, and a reason if invalid. The gate model never sees a tax question. It never computes anything. Its only job is to tell the system whether the document is a genuine, legible Nigerian bank statement that can support an income estimate.</p>
<p>If <code>valid</code> is <code>false</code>, the pipeline terminates. No analysis call fires. The user gets a clear failure reason and a prompt to upload a different document.</p>
<p>This gate serves two purposes simultaneously: it protects the more expensive analysis model from wasted calls on unusable input (a photo of a receipt, a foreign bank statement, a blank page), and it establishes the first hard boundary — before any income extraction begins, a human-interpretable validation has run.</p>
<h3>Tier 2: The Analysis (better model)</h3>
<p>If the gate passes, the analysis model runs. It receives the same PDF and a different question:</p>
<pre><code class="language-typescript">const AnalysisSchema = z.object({
  inflows: z.array(InflowSchema),
  grossAnnualKobo: z.number().int().nonnegative(),
});
</code></pre>
<p>The <code>inflows</code> array contains every credit the model found, each tagged with a classification: <code>salary</code>, <code>business</code>, <code>transfer</code>, or <code>other</code>. The <code>grossAnnualKobo</code> is the model's annualised income estimate in kobo.</p>
<p>Notice what the analysis model does <em>not</em> return: tax bands, relief amounts, effective rates, tax payable. It returns structured income data. Nothing more.</p>
<h3>Tier 3: The Tax Engine (pure code, no LLM)</h3>
<p>The tax engine is a pure TypeScript function. It receives <code>grossAnnualKobo</code> and a profile type, and it returns the full computation — band-by-band breakdown, applicable reliefs, both old and new regime figures, recommended regime. Every number the user sees on screen comes from here.</p>
<pre><code class="language-typescript">const computation = computeFromGross(profileType, grossAnnualKobo);
</code></pre>
<p><code>computeFromGross</code> calls <code>compareRegimes</code>, a deterministic function that encodes the NTA 2025 Fourth Schedule as code. It takes no model output as a parameter except the gross figure. It has no randomness, no retries, no API calls. Given the same inputs, it produces the same outputs. Forever.</p>
<p>This is the invariant in practice: the LLM hands off a single number (<code>grossAnnualKobo</code>) to a piece of code that knows the law. The code does the legal reasoning.</p>
<hr />
<h2>TrustRail: The Same Pattern at Different Stakes</h2>
<p>TrustRail is a BNPL underwriting platform. A business uploads a bank statement as part of a credit application. The system produces a trust score (0–100) and a decision: <code>APPROVED</code>, <code>FLAGGED_FOR_REVIEW</code>, or <code>DECLINED</code>.</p>
<p>The stakes are different from tax. A wrong tax estimate might result in a corrected filing. A wrong loan approval can trap a business in a debt it can't service.</p>
<p>The architecture is the same.</p>
<p>GPT-4o reads the uploaded PDF and returns a <code>TrustEngineAnalysisResult</code>. But the trust score inside that result is computed by <code>calculateTrustScore</code> — a pure TypeScript function in <code>trustEngineService.ts</code>. Five weighted buckets: income stability (30 points), spending behaviour (25 points), balance health (20 points), transaction behaviour (15 points), affordability (10 points). Pure arithmetic on structured transaction data.</p>
<pre><code class="language-typescript">const calculateTrustScore = (
  incomeAnalysis: IncomeAnalysis,
  spendingAnalysis: SpendingAnalysis,
  balanceAnalysis: BalanceAnalysis,
  behaviorAnalysis: BehaviorAnalysis,
  affordabilityAssessment: AffordabilityAssessment,
  installmentAmount: number
): number =&gt; {
  let score = 0;

  // Income Stability (30 points)
  score += incomeAnalysis.incomeConsistency * 15;
  const incomeToInstallmentRatio = installmentAmount / incomeAnalysis.avgMonthlyIncome;
  if (incomeToInstallmentRatio &lt; 0.2) score += 15;
  else if (incomeToInstallmentRatio &lt; 0.3) score += 10;
  else if (incomeToInstallmentRatio &lt; 0.4) score += 5;

  // ... four more buckets ...

  return Math.max(0, Math.min(100, Math.round(score)));
};
</code></pre>
<p>GPT-4o's job in TrustRail is identical to the analysis model in TaxLens: extract and classify. It reads the document format that the CSV parser can't handle, identifies income patterns, flags bounces, and returns structured data. The scoring is never delegated.</p>
<hr />
<h2>The <code>isValidStatement</code> Guard</h2>
<p>Both systems share one more hard boundary: before any analysis runs, the model is asked <em>only</em> whether the document is real.</p>
<p>In TaxLens, <code>gate.data.valid === false</code> short-circuits to a <code>failed</code> state before any analysis model call fires.</p>
<p>In TrustRail, the application service checks <code>analysisResult.isValidStatement</code>. If <code>false</code>, it calls <code>createInvalidStatementOutput</code> — a function that returns a fully zeroed <code>TrustEngineAnalysisResult</code> with <code>trustScore: 0</code> and <code>decision: 'DECLINED'</code>. The model's rejection reason is preserved for the audit trail. Everything else is zeroed.</p>
<pre><code class="language-typescript">if (analysisResult.isValidStatement === false) {
  analysisResult = createInvalidStatementOutput(
    analysisResult.invalidStatementReason || 'Document is not a valid bank statement',
    application.installmentAmount,
  );
}
</code></pre>
<p>This matters for a reason beyond correctness: it prevents document gaming. If an applicant uploads a forged or altered statement and the model flags it as invalid, the system doesn't try to extract income from it. It declines. The LLM's "I can't read this properly" is treated as a hard signal, not an error to retry through.</p>
<hr />
<h2>The Audit Trail Problem</h2>
<p>Every financial decision must be explainable and reproducible. If a loan applicant disputes a decline, you need to be able to reconstruct exactly why. If a tax authority questions a filed return, you need to show your working.</p>
<p>A language model's chain-of-thought is neither explainable nor reproducible in that sense. The same prompt, the same document, the same model version can produce different structured outputs on different days. Not wildly different — but measurably different in ways that matter when someone's loan application is on the line.</p>
<p>TaxLens maintains a separate <code>llm_audit</code> collection that records every model call: <code>tier</code>, <code>model</code>, <code>promptHash</code> (a SHA-256 of the system + user prompts — never raw statement text, for PII reasons), <code>inputTokens</code>, <code>outputTokens</code>, <code>latencyMs</code>, and <code>circuitState</code>. This records <em>what the LLM did</em>.</p>
<p>The <code>tax_process</code> document records <em>what the tax engine computed</em>: <code>grossAnnualKobo</code>, the full <code>computation</code> object with every band and relief, <code>inflows</code> with their classifications. This records <em>what the system decided</em>.</p>
<p>They're separate because they answer different accountability questions. The LLM audit answers: did the model behave correctly? The process record answers: how did we arrive at this tax figure? An auditor cares about the second. A debugging session cares about both.</p>
<hr />
<h2>Where to Draw the Line</h2>
<p>The pattern generalises. Here's the framework I use when deciding what a language model can own in a high-stakes pipeline:</p>
<p><strong>Permitted:</strong></p>
<ul>
<li><p><strong>Extraction</strong> — reading structure from unstructured input (transaction parsing, field extraction from PDFs, form recognition)</p>
</li>
<li><p><strong>Classification</strong> — labelling items against a defined taxonomy (salary vs. transfer, valid vs. invalid, income vs. refund)</p>
</li>
<li><p><strong>Validation</strong> — checking whether a document is what it claims to be</p>
</li>
</ul>
<p><strong>Forbidden:</strong></p>
<ul>
<li><p><strong>Calculation</strong> — arithmetic on figures that have legal, financial, or medical significance</p>
</li>
<li><p><strong>Decision</strong> — producing an outcome the user will act on (approved, declined, liable, not liable)</p>
</li>
<li><p><strong>Threshold application</strong> — checking a computed value against a rule ("does this score exceed the minimum?")</p>
</li>
</ul>
<p>The test is simple: if an auditor asks "why did you produce this number?", can you answer with a function call? If yes, the number belongs in code. If the answer is "because the model said so", the architecture needs to change.</p>
<hr />
<h2>Trade-offs and Honest Limitations</h2>
<p>This architecture is not free.</p>
<p><strong>The extraction step can still be wrong.</strong> If the analysis model misclassifies an inflow — calling a ₦500,000 monthly salary "transfer" — the tax engine will compute the correct tax on a wrong gross. The invariant protects the calculation. It does not protect the input. This is why TaxLens has a <code>needs_review</code> state (triggered when <code>grossAnnualKobo === 0</code> while inflows exist) and a manual reclassification flow. The user can correct what the model got wrong.</p>
<p><strong>The gate is a probabilistic validator, not a cryptographic one.</strong> A sufficiently realistic forged bank statement will pass the gate. The architecture reduces the blast radius — the scoring remains deterministic and the decision remains auditable — but it does not eliminate the risk of document fraud. That requires additional signals (account verification, BVN matching, real-time bank data feeds) that sit outside the LLM pipeline entirely.</p>
<p><strong>The two-call structure increases latency.</strong> Gate + analysis adds a sequential LLM round-trip before the tax engine can run. On fast connections with the right model tier choices (a cheap, fast model for the gate; a more capable model for analysis), this is tolerable. On slow network conditions or when using a single high-capability model for both tiers, the user waits longer than they would with a single call. The right choice depends on your rejection rate — if 20%+ of uploads hit an invalid document, the gate saves enough analysis calls to justify the extra latency even in the average case.</p>
<p><strong>Per-process singleton circuit breakers don't survive horizontal scale.</strong> TaxLens's <code>CircuitBreaker</code> is in-memory, per Node.js process. On a single instance, it works correctly: three consecutive OpenAI failures trip the breaker, and subsequent calls fast-fail for the cooldown period. On two instances, each has an independent breaker. One instance can be in <code>open</code> state while the other is <code>closed</code>, and the load balancer routes requests to whichever happens to answer. Moving the circuit state to Redis or MongoDB is the obvious fix; it was explicitly deferred as a v2 decision in the design notes, and it's the right call for an early product where single-instance deployments are the norm.</p>
<hr />
<h2>The A2 Guard: A Real Edge Case</h2>
<p>The <code>needs_review</code> state in TaxLens came from a real production observation, not speculation.</p>
<p>The Kuda MFB bank statement format produces credits with narrations like "Stac Intercontinental Ltd transfer" and "Abolarinwa Babafemi transfer." Both of those are income for the person receiving them — regular client payments, freelance work. But the word "transfer" appears in both narrations, and the analysis model, if it applies the classification guidance too literally, tags both as <code>transfer</code> rather than <code>business</code>.</p>
<p>The result: <code>grossAnnualKobo: 0</code>, despite <code>inflows</code> summing to ₦1.6M.</p>
<p>The guard:</p>
<pre><code class="language-typescript">const inflowsSumKobo = inflows.reduce((s, f) =&gt; s + f.amountKobo, 0);
const needsReview = grossAnnualKobo === 0 &amp;&amp; inflowsSumKobo &gt; 0;
</code></pre>
<p>If the model extracted credits but counted none as income, the pipeline routes to <code>needs_review</code> instead of <code>ready</code>. The user sees their inflows, selects which ones are actually income, and the system recomputes. The tax engine runs on corrected input.</p>
<p>This is the architectural response to a model classification error: don't try to prompt-engineer it away. Build a recovery path that keeps the human in the loop for the specific case where the model is known to drift.</p>
<hr />
<h2>Evaluation</h2>
<p>Across both systems, the pattern produces measurable properties:</p>
<p><strong>Reproducibility</strong> — Given the same extracted inflows or transactions, the tax engine and trust score engine produce the same output every time. This makes regression testing straightforward: fix a snapshot of extracted data, assert on the computed output.</p>
<p><strong>Auditability</strong> — Every figure can be traced to a specific function call with specific inputs. "Your trust score is 62" is backed by: here are the five bucket scores, here are the income figures that drove them, here are the transactions the classification step found.</p>
<p><strong>Fault isolation</strong> — When the model produces unexpected output (a misclassification, a wrong <code>grossAnnualKobo</code>), the error is contained to the extraction layer. The calculation layer sees structured data and computes correctly on whatever it receives. The bug is findable and fixable without touching the scoring logic.</p>
<p><strong>Testability</strong> — The tax engine and the trust scoring functions are pure TypeScript with no I/O. They can be tested exhaustively with fixtures. The LLM calls are tested with a stub transport (more on that in the next article).</p>
<hr />
<h2>The Honest Conclusion</h2>
<p>The pattern "LLM as parser, code as calculator" is not a clever trick. It's just a clear application of what language models are and are not reliable for, applied to a domain where being wrong has real consequences.</p>
<p>The LLM is a remarkable reader. It can handle document chaos that would require months of parser engineering. But it has no internal representation of the NTA 2025 Fourth Schedule, no guarantee that its arithmetic is consistent, and no awareness that the number it produces will be used to approve or decline someone's loan application.</p>
<p>Code has all of those properties. The combination is what makes the system trustworthy.</p>
<p><strong>The model earns the data. The code earns the answer.</strong></p>
]]></content:encoded></item><item><title><![CDATA[Event Driven Microfrontend at Scale: Connectic]]></title><description><![CDATA[Heyyyyy,
Picture this: you're working on a large frontend platform. Multiple teams. Multiple apps. Each team owns their microfrontend slice of the product, deploys it independently, and has the autono]]></description><link>https://crackedchefs.devferanmi.xyz/event-driven-microfrontend-at-scale-connectic</link><guid isPermaLink="true">https://crackedchefs.devferanmi.xyz/event-driven-microfrontend-at-scale-connectic</guid><dc:creator><![CDATA[Oluwaferanmi Adeniji]]></dc:creator><pubDate>Thu, 06 Nov 2025 09:30:00 GMT</pubDate><content:encoded><![CDATA[<p>Heyyyyy,</p>
<p>Picture this: you're working on a large frontend platform. Multiple teams. Multiple apps. Each team owns their microfrontend slice of the product, deploys it independently, and has the autonomy to make technical decisions within their boundary. It's the microfrontend dream.</p>
<p>Then a user logs in through the shell application. The auth token is refreshed. Three independent micro-apps each running in their own React context, each with their own memory — still hold the old token. One makes an API call. Gets a 401. Shows the user an error they don't understand.</p>
<p>The auth state changed. Nobody was notified. The failure was silent, intermittent, and incredibly annoying to reproduce.</p>
<p>This is not a hypothetical. It's the specific problem that led me to build Connectic.</p>
<p>Let's get cracking,</p>
<hr />
<h2>The Problem with Independent Frontends</h2>
<p>Microfrontends solve a real organisational problem: they let large teams work independently on different parts of a product without constant coordination overhead. Each team owns their code, their deployments, their technical choices. The product composes them at runtime.</p>
<p>The catch is that these independent apps still need to share state. They share the user's authentication context. They share user preferences (theme, language, notifications). They share data that one app produces and another needs to display. In a monolith, this is straightforward — everything is in the same memory. In a microfrontend architecture, the memory boundaries are real.</p>
<p>The three naive solutions all have serious problems at scale:</p>
<h3><code>window.postMessage</code></h3>
<p><code>postMessage</code> works for one-way broadcasts between frames. But there's no contract — you're passing strings or serialized objects with no type safety. If team A changes the shape of a <code>user:updated</code> message, team B's handler silently receives the wrong shape and does something unexpected. The only way to catch it is to have a human who knows about both sides.</p>
<p>At small scale (two apps, one team), this is manageable. At medium scale (eight apps, three teams, rotating engineers), it becomes a source of production bugs that are difficult to attribute.</p>
<h3><code>localStorage</code> with polling</h3>
<p>Reading a shared value from <code>localStorage</code> and polling it on an interval. It works. The problems: polling intervals are either too long (stale state for N seconds) or too short (unnecessary CPU and battery drain, especially on mobile). Updates don't propagate instantly — they propagate at the next poll tick. And polling across many components multiplies the overhead.</p>
<h3>Custom browser events</h3>
<p><code>document.dispatchEvent(new CustomEvent('auth:updated', { detail: user }))</code> — better than <code>postMessage</code>, but still one-way. There's no request/response primitive. There's no way to ask a question and wait for an answer. If the <code>auth</code> app that holds the canonical user state is slow to initialize and a consumer fires before it's ready, the event is lost with no recovery mechanism.</p>
<hr />
<h2>What Connectic Provides</h2>
<p>Connectic is a framework-agnostic communication library for microfrontend architectures. It runs on <code>window.__connectic</code> as a shared coordination singleton — any script loaded on the page can access it, regardless of framework, build tool, or deployment strategy.</p>
<p>Three primitives:</p>
<p><strong>Pub/sub</strong> — event-based communication for fire-and-forget notifications<br /><strong>Reactive state</strong> — shared state with real-time synchronisation across boundaries <strong>Request/response</strong> — ask/answer with caching and late-binding support</p>
<p>What makes Connectic useful is that they're implemented together, with a type-safe API, in a way that handles the lifecycle edge cases (late initialization, message replay, provider registration timing) that the naive alternatives don't.</p>
<h3>Pub/Sub</h3>
<pre><code class="language-typescript">// In the auth app — emit after token refresh
connectic.publish('auth:token-refreshed', { token: newToken, expiresAt });

// In any other app — react to the refresh
connectic.subscribe('auth:token-refreshed', ({ token, expiresAt }) =&gt; {
  apiClient.setAuthToken(token);
});
</code></pre>
<p>The contract is the event name and the payload shape. Both sides agree on what <code>auth:token-refreshed</code> carries. A TypeScript definition shared via a <code>@your-product/events</code> package makes this contractual — the compiler catches shape mismatches before they reach production.</p>
<h3>Reactive State</h3>
<p>Pub/sub works for events. But some shared values need to be readable at any time, not just when an event fires. A new micro-app initializing doesn't know what events have already fired. It needs to read the current state.</p>
<pre><code class="language-typescript">// Auth app sets user state after login
connectic.setState&lt;User&gt;('user', { id, name, role, hospitalId });

// Any app reads current user state on mount
const user = connectic.getState&lt;User&gt;('user');

// Any app subscribes to future changes
connectic.watchState&lt;User&gt;('user', (updatedUser) =&gt; {
  updateLocalContext(updatedUser);
});
</code></pre>
<p><code>getState</code> returns the current value synchronously — no wait, no subscription needed if you just want to read the value once. <code>watchState</code> subscribes to subsequent changes. An app that initializes after the state was set can call <code>getState</code> and get the current value immediately.</p>
<p>This eliminates the timing race that plagues event-only architectures: "did I miss the login event because my app loaded after auth already completed?"</p>
<h3>Request/Response with Caching</h3>
<p>Sometimes a micro-app needs data that another app owns, on-demand. "What are the current user's permissions?" — the permissions are owned by the auth app, but any app might need to check them.</p>
<pre><code class="language-typescript">// Auth app registers as the provider
connectic.provide('user:permissions', async () =&gt; {
  return fetchCurrentUserPermissions();
});

// Any app requests on-demand
const permissions = await connectic.request&lt;Permission[]&gt;('user:permissions');
</code></pre>
<p>The response is cached with a configurable TTL. The second app to call <code>connectic.request('user:permissions')</code> within the cache window gets the cached result without triggering another fetch. This is the pattern that eliminates the "every micro-app calls <code>/me</code> on mount" problem — the user data is fetched once by the app that owns it, and subsequent requests are served from cache.</p>
<hr />
<h2>The Lifecycle Edge Cases</h2>
<p>The primitives described above are straightforward. The engineering work is in the edge cases, the situations that only appear in production with real timing.</p>
<h3>Late Initialization</h3>
<p>A micro-app that subscribes to <code>auth:token-refreshed</code> might initialize after the auth app has already fired that event. In a naive pub/sub system, the subscription is registered too late and the event is missed permanently.</p>
<p>Connectic uses a brief replay buffer for pub/sub events: events fired within the last N milliseconds are replayed to new subscribers. The window is short enough to avoid replaying stale data, and long enough to cover the typical initialization timing gap between apps loaded from a CDN on the same page.</p>
<p>For reactive state, this isn't needed, <code>getState</code> always returns the current value regardless of when the subscriber joined.</p>
<h3>Provider Not Yet Ready</h3>
<p>For request/response, the provider might not be registered when the first consumer calls <code>connectic.request</code>. The consumer shouldn't fail, it should wait.</p>
<p><code>connectic.request</code> returns a Promise that resolves when a provider registers and responds. If the provider is already registered, it resolves immediately. If not, the Promise queues the request and resolves when the provider comes online. This makes initialization order irrelevant: consumers can safely call <code>request</code> before providers are ready.</p>
<p>This has a failure case: if the provider never registers (because the auth app crashed, or wasn't loaded), the Promise hangs. Connectic uses a configurable timeout — after N seconds without a provider response, the Promise rejects with a <code>ProviderTimeoutError</code>. The consumer handles this as an outage, not a hang.</p>
<h3>Cleanup</h3>
<p>Subscriptions and providers must be cleaned up when a micro-app unmounts. A subscription that's never removed will receive events after the subscribing component is gone — in React, this produces the classic "setState on unmounted component" warning. In worse cases, it leaks memory.</p>
<p>Connectic returns cleanup functions from <code>subscribe</code> and <code>provide</code>. In React, this pairs directly with <code>useEffect</code> return values:</p>
<pre><code class="language-typescript">useEffect(() =&gt; {
  const unsub = connectic.subscribe('auth:token-refreshed', handleTokenRefresh);
  return unsub; // cleanup on unmount
}, []);
</code></pre>
<hr />
<h2>The Type Safety Strategy</h2>
<p>Cross-app communication without type safety is a liability. The contract between apps is implicit — a developer changing the shape of an event payload doesn't know who's consuming it or whether their change breaks anything.</p>
<p>Connectic's type system works through generics: <code>connectic.publish&lt;T&gt;(event, payload)</code>, <code>connectic.request&lt;T&gt;(key)</code>, <code>connectic.getState&lt;T&gt;(key)</code>. The types are specified at the call site.</p>
<p>The stronger pattern is a shared event catalogue. A <code>@your-product/connectic-types</code> package defines the event names and their payload types:</p>
<pre><code class="language-typescript">// @your-product/connectic-types
export interface ConnecticEvents {
  'auth:token-refreshed': { token: string; expiresAt: number };
  'auth:logged-out': { reason: 'timeout' | 'manual' };
  'user:preferences-updated': { theme: 'light' | 'dark'; language: string };
}

export interface ConnecticState {
  user: User | null;
  theme: 'light' | 'dark';
}
</code></pre>
<p>Every app that publishes or subscribes imports from this package. Shape changes require a package version bump. Consumers that haven't updated to the new types get TypeScript errors pointing exactly to the changed call sites.</p>
<p>This doesn't fully solve the deployment coordination problem (two apps running different versions of the type package won't catch mismatches at runtime), but it catches mismatches at build time within a CI pipeline that builds all apps together.</p>
<hr />
<h2>Trade-offs</h2>
<p><code>window.__connectic</code> <strong>is a global.</strong> This is a deliberate choice, it's what makes framework-agnostic communication possible without a shared bundler. The cost: any script on the page can read or write to Connectic's state. This is the same trust model as <code>localStorage</code> or <code>window.postMessage</code>, and it requires the same kind of defense: validate your inputs, don't put secrets in shared state, treat cross-app data as coming from an untrusted source just as you'd treat any cross-origin message.</p>
<p><strong>In-memory state is not durable.</strong> A page reload clears Connectic's reactive state. If a micro-app needs to preserve state across page loads, it should persist to <code>localStorage</code> or <code>sessionStorage</code> and hydrate Connectic state from there on initialization. Connectic is a communication bus, not a storage layer.</p>
<p><strong>The pub/sub replay buffer can produce stale re-deliveries.</strong> If an event is fired, and a slow-loading app subscribes within the replay window, it will receive the event. This is usually desirable. In cases where the event is "user clicked confirm on this destructive action," replaying to a late subscriber could cause an unintended second effect. The solution is to design events that are idempotent — receiving the same event twice should produce the same result as receiving it once. This is a general distributed systems principle, not a Connectic-specific issue, but it's worth internalizing when designing your event catalogue.</p>
<p><strong>Per-process circuit breakers don't survive horizontal scale.</strong> Wait, wrong article. But the in-memory nature of Connectic state is the analogous concern: on a server-rendered microfrontend architecture (where apps are rendered server-side and composed), Connectic's client-side state model doesn't apply cleanly. It's designed for client-side rendered or hydrated microfrontends, not server-rendered shell/fragment compositions.</p>
<p><strong>The request/response pattern is point-to-point, not broadcast.</strong> <code>connectic.request('user:permissions')</code> calls the registered provider. If two apps register as providers for the same key (a misconfiguration), Connectic uses the last-registered provider. There's no multi-provider fanout or consensus model — which is intentional simplicity, but it means you need clear conventions about which app owns which request key.</p>
<hr />
<h2>When You Don't Need This</h2>
<p>A word on over-engineering, because it's the more common failure mode:</p>
<p>If your "microfrontends" are feature folders in a single Vite project that share the same build output, you don't have cross-boundary communication needs. You have a folder structure problem. The previous article in this series (FSD) handles that case.</p>
<p>Connectic makes sense when:</p>
<ul>
<li><p>Multiple apps are deployed independently with separate CI pipelines</p>
</li>
<li><p>Different teams own different apps and may be running different framework versions</p>
</li>
<li><p>The apps genuinely run as separate JavaScript bundles on the same page</p>
</li>
<li><p>You've been bitten by the auth token / stale state problem described in the opening</p>
</li>
</ul>
<p>If you're not in that situation, a shared React context or a Redux store is the right tool. Connectic is the right tool for genuine cross-bundle, cross-team state coordination — not as a replacement for state management inside a single app.</p>
<hr />
<h2>Evaluation</h2>
<p>Connectic has been used in production microfrontend setups across several applications. The properties it provides:</p>
<p><strong>Team independence</strong> — Teams can publish events and register state keys without coordinating with every other team. The contract is the event catalogue. Nothing else requires coordination.</p>
<p><strong>Late-binding correctness</strong> — Apps that initialize in any order still receive current state and queued requests. Initialization timing is not a source of bugs.</p>
<p><strong>Observable communication</strong> — Because all communication flows through <code>window.__connectic</code>, it's possible to instrument it centrally. A <code>connectic.debug()</code> mode logs all published events, state changes, and request/response cycles to the console. Debugging cross-app state issues becomes a matter of opening DevTools, not adding <code>console.log</code> to five different repos.</p>
<p><strong>Framework agnosticism</strong> — A Vue app and a React app on the same page communicate through the same Connectic instance without either knowing what framework the other is using. This is the property that module federation and import maps can struggle to provide cleanly.</p>
<hr />
<h2>The Honest Conclusion</h2>
<p>Microfrontend state coordination is one of those problems that's easy to underestimate until you've been burned by it. The naive solutions (postMessage, localStorage polling, custom events) each work for simple cases and each fail in specific ways as the number of apps and teams grows.</p>
<p>Connectic exists because I needed the pub/sub + reactive state + request/response combination to work reliably with proper lifecycle handling, in a framework-agnostic way, without requiring all teams to use the same build toolchain.</p>
<p>The 3,000+ npm downloads suggest other people needed the same thing. If you're in the same situation, it's on npm and open source. If you're not, don't add it to a project that doesn't need it.</p>
<p><strong>Use the simplest tool that solves the actual problem. Add complexity when the simpler tool fails.</strong></p>
]]></content:encoded></item><item><title><![CDATA[Maybe You Only Need Vanilla JavaScript: Challenging the Framework-First Mindset]]></title><description><![CDATA[Have you ever reached for create-react-app to build a simple counter? Or spun up Next.js for a page with three buttons? If you're nodding your head, you're not alone. Somewhere between 2015 and now, we collectively decided that frameworks aren't just...]]></description><link>https://crackedchefs.devferanmi.xyz/maybe-you-only-need-vanilla-javascript-challenging-the-framework-first-mindset</link><guid isPermaLink="true">https://crackedchefs.devferanmi.xyz/maybe-you-only-need-vanilla-javascript-challenging-the-framework-first-mindset</guid><category><![CDATA[React]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Frontend Development]]></category><category><![CDATA[Front-end Development]]></category><dc:creator><![CDATA[Oluwaferanmi Adeniji]]></dc:creator><pubDate>Mon, 03 Nov 2025 23:00:00 GMT</pubDate><content:encoded><![CDATA[<p>Have you ever reached for <code>create-react-app</code> to build a simple counter? Or spun up Next.js for a page with three buttons? If you're nodding your head, you're not alone. Somewhere between 2015 and now, we collectively decided that frameworks aren't just useful, they're mandatory.</p>
<p><strong>Note:</strong> <em>This article is based on my presentation at DevFest Ibadan 2025, hosted by Google Developer Group Ibadan on November 1, 2025, What started as a 45-minute talk about challenging our framework-first assumptions has been expanded here with additional examples, deeper technical explanations, and code you can actually run. If you were in the audience that day, thank you for the great questions. If you weren't, welcome to the conversation.</em></p>
<p>But what if I told you that for a significant portion of what we build, we're shipping 120KB of abstraction to solve problems we don't actually have?</p>
<p>Let me be clear upfront: <strong>This isn't an anti-framework rant.</strong> React, Vue, and Angular exist for excellent reasons, and we'll talk about those reasons honestly. But the pendulum has swung so far toward "framework-first" thinking that we've forgotten to ask a simple question: <em>Do I actually need this?</em></p>
<p>Today, we're challenging that default assumption. We'll compare vanilla JavaScript and React side-by-side, peek under the hood at what frameworks actually do, and most importantly, learn when each tool makes sense.</p>
<p>And if you're in Nigeria (or anywhere with expensive data and unreliable internet), this conversation isn't academic. Every kilobyte you ship costs your users money and patience.</p>
<p>Let's dig in.</p>
<hr />
<h2 id="heading-what-is-vanilla-javascript-anyway">What is "Vanilla" JavaScript Anyway?</h2>
<p>The first time I heard "vanilla JavaScript," I was genuinely confused. Is this a different language? A library I missed?</p>
<p>Think of vanilla ice cream. It's the base flavor, no toppings, no mix-ins, just pure ice cream. Other flavors are built on top of it:</p>
<ul>
<li><p>Chocolate = Vanilla + Cocoa</p>
</li>
<li><p>Strawberry = Vanilla + Strawberries</p>
</li>
<li><p>Mint Chip = Vanilla + Mint + Chocolate chips</p>
</li>
</ul>
<p><strong>Vanilla JavaScript = JavaScript without any additions.</strong> No React. No Vue. No frameworks, no libraries. Just the language as it exists in the browser.</p>
<p>A quick history note: JavaScript was created by Brendan Eich at Netscape in 1995, reportedly in just 10 days. It was originally called Mocha, then LiveScript, before becoming JavaScript. The problem it solved was simple but crucial: websites needed interactivity without full page reloads. Before JavaScript, every filter, every form submission, every tiny interaction required a server round-trip.</p>
<p>JavaScript has three parts:</p>
<ol>
<li><p><strong>The Language</strong>: Syntax, operators, data types, functions, objects, classes</p>
</li>
<li><p><strong>The Runtime APIs</strong>: Browser APIs (DOM, Fetch, localStorage) or Node.js APIs (fs, http, process)</p>
</li>
<li><p><strong>The Ecosystem</strong>: npm packages, build tools, developer tools, community</p>
</li>
</ol>
<p>When we talk about "Vanilla JavaScript," we're talking about using parts 1 and 2 without adding layers from part 3.</p>
<hr />
<h2 id="heading-the-golden-age-before-frameworks">The Golden Age Before Frameworks</h2>
<p>Before React arrived in 2013 (public release in 2015), and before Angular came even earlier, building with vanilla JavaScript had some genuinely beautiful qualities:</p>
<h3 id="heading-1-tiny-footprint">1. Tiny Footprint</h3>
<p>You didn't need to import anything. Write HTML, add a <code>&lt;script&gt;</code> tag, start coding. Zero bytes of dependencies.</p>
<h3 id="heading-2-zero-build-time">2. Zero Build Time</h3>
<p>No webpack. No Babel. No "waiting for the build to finish." You wrote code, hit refresh, and saw results instantly.</p>
<h3 id="heading-3-simple-deployment">3. Simple Deployment</h3>
<p>Remember those hosting platforms where you just upload a ZIP file? You'd create <code>index.html</code>, link your CSS and JS files, upload everything, and you were done. No build servers, no CI/CD pipelines, no deployment configurations.</p>
<h3 id="heading-4-no-frameworkvendor-lock-in">4. No Framework/Vendor Lock-In</h3>
<p>Your code was <em>yours</em>. If a new tool came along, you could adopt it gradually. You weren't married to React's release schedule or Vue's breaking changes.</p>
<h3 id="heading-5-lightweight-dev-environment">5. Lightweight Dev Environment</h3>
<p>These days, dev servers can take 10 minutes to reload on large React apps. You make a change, go get water, come back, and <em>maybe</em> it's finished recompiling. With vanilla JS? Instant feedback. Change file, refresh browser, done.</p>
<p>This sounds like paradise, right?</p>
<hr />
<h2 id="heading-why-frameworks-won-and-why-that-made-sense">Why Frameworks Won (And Why That Made Sense)</h2>
<p>Frameworks didn't take over because developers are sheep who follow trends. They took over because they solved <em>real, painful problems</em>:</p>
<h3 id="heading-1-cross-browser-nightmare">1. Cross-Browser Nightmare</h3>
<p>Different browsers implemented JavaScript features differently. Getting user location? Different APIs. CSS animations? Special code for Internet Explorer. Event handling? Safari did it differently than Chrome. You'd write one version for modern browsers, another for IE, and pray it worked on mobile.</p>
<p>Frameworks with bundlers like webpack and Babel would transpile your code to work everywhere. Write once, run anywhere.</p>
<h3 id="heading-2-manual-dom-hell">2. Manual DOM Hell</h3>
<p>Let's say you're building a counter. In vanilla JS, you had to:</p>
<pre><code class="lang-javascript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">increment</span>(<span class="hljs-params"></span>) </span>{
    count++;  <span class="hljs-comment">// Data changed</span>
    <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'display'</span>).innerHTML = count;  <span class="hljs-comment">// Manually update UI</span>
    <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'badge'</span>).innerHTML = count;    <span class="hljs-comment">// Manually update UI again</span>
    <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'sidebar'</span>).innerHTML = count;  <span class="hljs-comment">// And again...</span>
    <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'btn'</span>).disabled = count &gt;= <span class="hljs-number">10</span>; <span class="hljs-comment">// Don't forget this one!</span>
    <span class="hljs-comment">// Easy to forget one. Bugs everywhere.</span>
}
</code></pre>
<p>Every single place that displays the count needs manual updating. Miss one? Your UI desyncs from your data. This is tedious and error-prone.</p>
<h3 id="heading-3-harder-reusability-more-complexity-for-reuse">3. Harder Reusability / More Complexity for Reuse</h3>
<p>Want to reuse a component? In vanilla JS, you'd have to manually structure functions, manage HTML templates, handle initialization. There was no standard component model.</p>
<h3 id="heading-4-state-chaos-global-variables-and-collisions">4. State Chaos - Global Variables and Collisions</h3>
<p>You'd import different script files, and if two scripts happened to use the same variable name, you were in trouble. No encapsulation meant collision risks everywhere.</p>
<p>React components are enclosed. Everything you define inside a component stays inside it.</p>
<h3 id="heading-5-untestable-code-tightly-coupled-harder-to-maintain">5. Untestable Code - Tightly Coupled, Harder to Maintain</h3>
<p>Look at this code:</p>
<pre><code class="lang-javascript"><span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'balance'</span>).textContent = patricia.getWalletBalance();
</code></pre>
<p>This is <em>tightly coupled</em> to both the HTML (via that specific ID) and the Patricia API. If Patricia shuts down tomorrow (like they did recently), you have to find every single line that references Patricia. If someone changes that <code>balance</code> ID in the HTML, this breaks silently.</p>
<p>Better practice? Write a facade:</p>
<pre><code class="lang-javascript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getWalletBalance</span>(<span class="hljs-params"></span>) </span>{
    <span class="hljs-keyword">return</span> patricia.getWalletBalance();  <span class="hljs-comment">// Can easily swap to Flutterwave</span>
}
</code></pre>
<p>But vanilla JS didn't enforce these patterns. Frameworks did.</p>
<hr />
<h2 id="heading-what-reactvueangular-actually-gave-us">What React/Vue/Angular Actually Gave Us</h2>
<p>Frameworks earned their dominance by providing five critical improvements:</p>
<h3 id="heading-1-component-reusability">1. Component Reusability</h3>
<p>Write a button component once, use it everywhere. Clear, modular, maintainable.</p>
<h3 id="heading-2-declarative-ui">2. Declarative UI</h3>
<p>Instead of saying "find this element, change its text, update this other element," you say "when count is 5, the UI looks like this." You describe <em>what</em> should be rendered, not <em>how</em> to update it.</p>
<h3 id="heading-3-reactive-data-binding">3. Reactive Data Binding</h3>
<p>Change the data, UI updates automatically. No manual syncing.</p>
<h3 id="heading-4-rich-ecosystems">4. Rich Ecosystems</h3>
<p>Thousands of libraries, established patterns, battle-tested solutions. Need routing? There's a library. Need state management? Multiple options.</p>
<h3 id="heading-5-team-alignment">5. Team Alignment</h3>
<p>Everyone knows React. Hiring is easier. Onboarding is faster. "Let's build this in React" is a no-brainer for team coordination.</p>
<p>These are <em>real</em> benefits. Frameworks won for good reasons.</p>
<hr />
<h2 id="heading-but-everything-has-a-cost">But Everything Has a Cost</h2>
<p>Here's the part we don't talk about enough. When you choose a framework, you're not just gaining features. You're accepting trade-offs. And it's not just you as a developer who pays, your users, your team, and your company all bear the costs.</p>
<h3 id="heading-the-trade-offs-we-accepted">The Trade-offs We Accepted:</h3>
<p><strong>1. Bundle Size Bloat</strong><br />That simple counter app? If you build it with React, you're shipping ~50-120KB of React library code just to count clicks. For users on slow networks or expensive data plans, that's pain.</p>
<p><strong>2. Build Complexity</strong><br />Most frameworks needs to be compiled, transpiled, bundled. Every change requires a build step, except you use the CDN versions (Content delivery network)</p>
<p><strong>3. Framework Lock-In</strong><br />If React is sunsetted tomorrow, you're in trouble. Companies literally pay framework maintainers to keep building because they know if development stops, their apps are stuck. Facebook gave React $1.5-2.5 million recently to ensure continued development.</p>
<p><strong>4. Abstraction Layers</strong><br />React hides how the DOM actually works. If you only know React, you don't know <code>getElementById</code> vs <code>querySelector</code> performance differences, or which DOM APIs are fast vs slow.</p>
<p><strong>5. Easier to "Mess Things Up"</strong><br />With React, you start importing libraries for everything. Form validation? Import a library. State management? Import Redux. Before you know it, you have 47 dependencies to do things that are built into the browser.</p>
<p>I once saw an engineer spend two days trying to pause background audio when users scrolled away in a TikTok-style app. He was managing Redux state, tracking which audios were playing, dispatching pause actions...</p>
<p>The vanilla solution? <code>document.querySelectorAll('audio').forEach(audio =&gt; audio.pause())</code>. Done.</p>
<p><strong>6. Long-Running Servers</strong><br />With Server-Side Rendering (SSR), Static Site Generation (SSG), and other modern framework features, you now need servers that actively process requests. This is different from just serving static HTML files. Your hosting costs go up.</p>
<p><strong>7. Hosting/Cloud Costs</strong><br />SSR, incremental regeneration, edge functions, all these cost money. Vercel, Netlify, and others make their money here.</p>
<p><strong>8. Time</strong><br />Every library you add increases load time. For users in poor network coverage areas, this isn't theoretical—it's the difference between your app loading or not.</p>
<p><strong>9. Trickle-Down Panic When Things Go Wrong</strong><br />Remember the npm supply chain attacks a few months ago? Libraries got hacked, scripts started reading <code>.env</code> files and uploading secrets to remote servers. If you're using vanilla HTML and CSS, you're not running <code>npm install</code> and you wouldn't have been affected.</p>
<p>Axios had a security vulnerability six months ago that could let attackers manipulate API requests. If you're on an old version and don't know, you're exposed.</p>
<p><strong>10. Cognitive Complexity for New Engineers</strong><br />You just learned JavaScript. Variables return strings, numbers, functions. Now someone shows you React and you see <code>return &lt;div&gt;Hello&lt;/div&gt;</code>. Wait, we're returning HTML? And why is it <code>className</code> instead of <code>class</code>? There's a learning curve just to understand the abstractions.</p>
<hr />
<h2 id="heading-feature-showdown-reactivity-amp-state">Feature Showdown: Reactivity &amp; State</h2>
<p>Let's get concrete. I'm going to show you the same app built two ways, and we'll compare them across eight dimensions.</p>
<h3 id="heading-the-challenge">The Challenge</h3>
<p>Build a car counter app. You're standing by a roadside counting BMWs and Mercedes-Benz cars that pass by. The app needs:</p>
<ul>
<li><p>A button for BMW (increments BMW count)</p>
</li>
<li><p>A button for Benz (increments Benz count)</p>
</li>
<li><p>Display total count (sum of both)</p>
</li>
<li><p>All displays update automatically</p>
</li>
</ul>
<p>Simple, right? Let's see both approaches.</p>
<hr />
<h3 id="heading-the-vanilla-js-approach">The Vanilla JS Approach</h3>
<pre><code class="lang-javascript"><span class="hljs-keyword">let</span> benzCount = <span class="hljs-number">0</span>;
<span class="hljs-keyword">let</span> totalCount = <span class="hljs-number">0</span>;

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">incrementBenzCount</span>(<span class="hljs-params"></span>) </span>{
    benzCount++;  <span class="hljs-comment">// Data changed...</span>
    totalCount++; <span class="hljs-comment">// Another data changed...</span>
    <span class="hljs-built_in">document</span>.querySelector(<span class="hljs-string">'.benz-count'</span>).textContent = benzCount;  <span class="hljs-comment">// But we manually update UI</span>
    <span class="hljs-built_in">document</span>.querySelector(<span class="hljs-string">'.total-count'</span>).textContent = totalCount;
    <span class="hljs-comment">// Imagine doing this everywhere...</span>
}
</code></pre>
<p><strong>What happens:</strong></p>
<ol>
<li><p>Increment the <code>benzCount</code> variable</p>
</li>
<li><p>Increment the <code>totalCount</code> variable</p>
</li>
<li><p>Find the <code>.benz-count</code> element in the DOM</p>
</li>
<li><p>Update its text content</p>
</li>
<li><p>Find the <code>.total-count</code> element in the DOM</p>
</li>
<li><p>Update its text content</p>
</li>
</ol>
<p>Four lines of code inside the function, two variables declared outside. And this is just for <em>one</em> button. The BMW button needs the same treatment.</p>
<p>You can wrap the <code>querySelector</code> calls in a helper function to reduce repetition:</p>
<pre><code class="lang-javascript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">updateContent</span>(<span class="hljs-params">selector, value</span>) </span>{
    <span class="hljs-built_in">document</span>.querySelector(selector).textContent = value;
}

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">incrementBenzCount</span>(<span class="hljs-params"></span>) </span>{
    benzCount++;
    totalCount++;
    updateContent(<span class="hljs-string">'.benz-count'</span>, benzCount);
    updateContent(<span class="hljs-string">'.total-count'</span>, totalCount);
}
</code></pre>
<p>Slightly cleaner, but still the same four lines of logic.</p>
<hr />
<h3 id="heading-the-react-approach">The React Approach</h3>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> [benzCount, setBenzCount] = useState(<span class="hljs-number">0</span>);
<span class="hljs-keyword">const</span> [bmwCount, setBmwCount] = useState(<span class="hljs-number">0</span>);
<span class="hljs-keyword">const</span> totalCount = benzCount + bmwCount;  <span class="hljs-comment">// Derive data automatically</span>

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">incrementBenzCount</span>(<span class="hljs-params"></span>) </span>{
    setBenzCount(benzCount + <span class="hljs-number">1</span>);  <span class="hljs-comment">// Just update data</span>
}
</code></pre>
<p><strong>What happens:</strong></p>
<ol>
<li><p>Call <code>setBenzCount(benzCount + 1)</code></p>
</li>
<li><p>React handles everything else—UI updates automatically</p>
</li>
</ol>
<p>Notice something crucial: <code>totalCount</code> isn't a separate state. It's <strong>derived state</strong>, calculated from other state. This is a React best practice. Don't create redundant state; derive it.</p>
<hr />
<h3 id="heading-deep-dive-the-8-dimensions-of-comparison">Deep Dive: The 8 Dimensions of Comparison</h3>
<p>Let's analyze these two approaches across multiple dimensions.</p>
<h4 id="heading-1-declarative-vs-imperative"><strong>1. Declarative vs Imperative</strong></h4>
<p><strong>React (Declarative):</strong></p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> totalCount = benzCount + bmwCount;  <span class="hljs-comment">// Declare relationship once</span>
<span class="hljs-comment">// UI automatically updates when dependencies change. You describe WHAT, not HOW.</span>
</code></pre>
<p><strong>Vanilla (Imperative):</strong></p>
<pre><code class="lang-javascript">benzCount++;
updateContent(<span class="hljs-string">'.benz-count'</span>, benzCount);  <span class="hljs-comment">// Manual sync</span>
<span class="hljs-comment">// You explicitly tell the DOM what to do, every single time. Easy to forget steps.</span>
</code></pre>
<p>React lets you declare "total count equals the sum of these two values," and it handles keeping the UI in sync. Vanilla requires you to manually execute the sync steps each time.</p>
<h4 id="heading-2-cognitive-load"><strong>2. Cognitive Load</strong></h4>
<p><strong>React: Lower</strong><br />You think about state, not DOM manipulation. Mental model = "data flows down." Change the data, React handles the rest.</p>
<p><strong>Vanilla: Higher</strong><br />You must remember: (1) update variable, (2) find DOM node, (3) update it. Three-step process for every change. Easy to desync if you miss a step.</p>
<h4 id="heading-3-bug-probability"><strong>3. Bug Probability</strong></h4>
<p>In programming, more code = more potential bugs. If you write 10 lines, you can introduce up to 10 bugs (one per line). If you write 100 lines, up to 100 bugs.</p>
<p><strong>React:</strong><br />Fewer steps = fewer bugs. You change state, React handles rendering.</p>
<p><strong>Vanilla:</strong><br />More steps = more opportunities for errors. Forgot to update one display? Typo in a selector? Wrong element ID? Each is a potential bug.</p>
<h4 id="heading-4-memory-footprint"><strong>4. Memory Footprint</strong></h4>
<p><strong>React:</strong><br />~50-120KB (gzipped) for the React library<br />Virtual DOM (~3x actual DOM size in memory)<br />Heavy upfront cost</p>
<p><strong>Vanilla:</strong><br />~0KB overhead. Just your variables. The JavaScript engine is already in the browser—you don't import anything.</p>
<p><strong>The catch:</strong> For small apps (&lt;10KB), React's overhead is significant. For large apps (&gt;5MB), React's 120KB becomes negligible as a percentage of total size.</p>
<h4 id="heading-5-execution-speed-runtime"><strong>5. Execution Speed (Runtime)</strong></h4>
<p><strong>React:</strong><br />Slower per update. State change → diffing algorithm → batch updates → DOM patch = ~1-5ms per update cycle.</p>
<p><strong>Vanilla:</strong><br />Faster per update. Direct DOM manipulation = ~0.1ms per update.</p>
<p><strong>The catch:</strong> For 1-100 updates, the difference is negligible. For 10,000 updates/second (like a data visualization or game), vanilla wins.</p>
<h4 id="heading-6-what-happens-behind-the-hood"><strong>6. What Happens Behind The Hood</strong></h4>
<p><strong>React:</strong></p>
<ol>
<li><p>Triggers re-render</p>
</li>
<li><p>React diffs the Virtual DOM against the previous version</p>
</li>
<li><p>Batches changes together</p>
</li>
<li><p>Updates only the changed DOM nodes</p>
</li>
<li><p>Calls useEffect hooks</p>
</li>
</ol>
<p><strong>Vanilla JS:</strong></p>
<ol>
<li><p><code>querySelector</code> finds the node</p>
</li>
<li><p>Sets <code>textContent</code> directly</p>
</li>
<li><p>Done</p>
</li>
</ol>
<p>React does more work, but that work provides benefits: batching prevents multiple repaints, diffing ensures minimal DOM changes.</p>
<h4 id="heading-7-state-consistency"><strong>7. State Consistency</strong></h4>
<p><strong>React:</strong><br />Single source of truth. The state (<code>benzCount</code>) is the source of truth. UI always matches state (eventually). Impossible to desync.</p>
<p><strong>Vanilla:</strong><br />Dual sources of truth. Is the truth the <code>benzCount</code> variable? Or the DOM? Or both? If you update the variable but forget to update the DOM, they're out of sync.</p>
<h4 id="heading-8-memory-leaks"><strong>8. Memory Leaks</strong></h4>
<p><strong>React:</strong><br />Rare. Cleanup happens automatically when components unmount (unless you misuse <code>useEffect</code> by forgetting to return a cleanup function).</p>
<p><strong>Vanilla:</strong><br />Common. Forgot to remove an event listener? Memory leak. You declared a variable and attached an event listener:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> name = <span class="hljs-string">"feranmi"</span>;  <span class="hljs-comment">// Compilation assigns memory block "1101"</span>
<span class="hljs-built_in">document</span>.addEventListener(<span class="hljs-string">'click'</span>, handler);  <span class="hljs-comment">// Assigns another memory block</span>
<span class="hljs-comment">// If you never remove this listener, the memory stays occupied forever</span>
</code></pre>
<p>When JavaScript compiles your code, it assigns memory addresses to variables. Let's say <code>name</code> gets memory address <code>1101</code>. That address is marked as "occupied" and stores the value <code>"feranmi"</code>.</p>
<p>If you never clean up (by removing event listeners or clearing variables), those memory blocks stay occupied even after you're done with them. The browser's garbage collector can't reclaim them because they're still referenced.</p>
<p>React manages this automatically. When a component unmounts, React cleans up event listeners and state. With vanilla JS, you're responsible for cleanup.</p>
<hr />
<h2 id="heading-but-waityou-can-do-reactivity-in-vanilla">But Wait—You CAN Do Reactivity in Vanilla</h2>
<p>Everything I just showed you about React's reactivity? You can build it yourself in vanilla JavaScript in about 10 lines of code.</p>
<p>Meet <strong>JavaScript Proxies</strong>.</p>
<h3 id="heading-building-your-own-usestate">Building Your Own useState</h3>
<pre><code class="lang-javascript"><span class="hljs-comment">// 1. Create reactive state</span>
<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">createReactiveState</span>(<span class="hljs-params">initialState, callback</span>) </span>{
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Proxy</span>(initialState, {
        set(target, property, value) {
            target[property] = value;
            callback();  <span class="hljs-comment">// Notify on change</span>
            <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;
        }
    });
}

<span class="hljs-comment">// 2. Use it</span>
<span class="hljs-keyword">const</span> state = createReactiveState(
    { <span class="hljs-attr">count</span>: <span class="hljs-number">0</span> },
    <span class="hljs-function">() =&gt;</span> render()
);

<span class="hljs-comment">// 3. Render function (template literal)</span>
<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">render</span>(<span class="hljs-params"></span>) </span>{
    <span class="hljs-keyword">const</span> { count } = state;
    <span class="hljs-built_in">document</span>.querySelector(<span class="hljs-string">'#app'</span>).innerHTML = <span class="hljs-string">`
        &lt;p&gt;Count: <span class="hljs-subst">${count}</span>&lt;/p&gt;
        &lt;p&gt;Double: <span class="hljs-subst">${count * <span class="hljs-number">2</span>}</span>&lt;/p&gt;
        &lt;p&gt;Status: <span class="hljs-subst">${count &gt; <span class="hljs-number">5</span> ? <span class="hljs-string">'High'</span> : <span class="hljs-string">'Low'</span>}</span>&lt;/p&gt;
        &lt;button onclick="state.count++"&gt;+&lt;/button&gt;
    `</span>;
}

<span class="hljs-comment">// Initial render</span>
render();
</code></pre>
<p><strong>What happens:</strong></p>
<ol>
<li><p><strong>State Proxy</strong>: Wraps your state object in a Proxy that intercepts changes</p>
</li>
<li><p><strong>Usage</strong>: When you do <code>state.count++</code>, the Proxy's <code>set</code> trap fires</p>
</li>
<li><p><strong>Trigger Rendering</strong>: The callback (<code>render()</code>) is called automatically</p>
</li>
</ol>
<p>You've just built React's <code>useState</code> in vanilla JavaScript.</p>
<h3 id="heading-important-caveat">Important Caveat</h3>
<p>This isn't production-ready. To truly replicate React, you'd need to add:</p>
<ul>
<li><p><strong>Batching updates</strong> (React doesn't re-render 10 times if you change 10 state values; it batches them)</p>
</li>
<li><p><strong>Diffing algorithm</strong> (React only updates changed DOM nodes, not the entire tree)</p>
</li>
<li><p><strong>Lifecycle hooks</strong> (equivalent to <code>useEffect</code>)</p>
</li>
<li><p><strong>Memory leak prevention</strong> (cleanup when components unmount)</p>
</li>
</ul>
<p>But the core concept? You just built it. React isn't magic, it's JavaScript using browser APIs cleverly.</p>
<hr />
<h2 id="heading-feature-showdown-components-amp-reusability">Feature Showdown: Components &amp; Reusability</h2>
<p>One of the biggest selling points for frameworks is component reusability. Let's see how both approaches handle this.</p>
<h3 id="heading-the-challenge-1">The Challenge</h3>
<p>Build a <code>&lt;ProductCard&gt;</code> component that displays:</p>
<ul>
<li><p>Product image</p>
</li>
<li><p>Product name</p>
</li>
<li><p>Product price</p>
</li>
<li><p>"Add to Cart" button</p>
</li>
</ul>
<hr />
<h3 id="heading-the-react-way">The React Way</h3>
<pre><code class="lang-javascript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">ProductCard</span>(<span class="hljs-params">{ product, onAddToCart }</span>) </span>{
    <span class="hljs-keyword">return</span> (
        <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"product-card"</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">img</span> <span class="hljs-attr">src</span>=<span class="hljs-string">{product.image}</span> <span class="hljs-attr">alt</span>=<span class="hljs-string">{product.name}</span> /&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">h3</span>&gt;</span>{product.name}<span class="hljs-tag">&lt;/<span class="hljs-name">h3</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">p</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"price"</span>&gt;</span>${product.price}<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">onClick</span>=<span class="hljs-string">{()</span> =&gt;</span> onAddToCart(product)}&gt;
                Add to Cart
            <span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
        <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span>
    );
}

<span class="hljs-comment">// Usage</span>
&lt;ProductCard product={product} onAddToCart={handleAdd} /&gt;
</code></pre>
<p>Clean, familiar, composable. This only works in React.</p>
<hr />
<h3 id="heading-the-web-components-way">The Web Components Way</h3>
<pre><code class="lang-javascript"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ProductCard</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">HTMLElement</span> </span>{
    connectedCallback() {
        <span class="hljs-keyword">const</span> product = <span class="hljs-built_in">JSON</span>.parse(<span class="hljs-built_in">this</span>.getAttribute(<span class="hljs-string">'product'</span>));

        <span class="hljs-built_in">this</span>.innerHTML = <span class="hljs-string">`
            &lt;div class="product-card"&gt;
                &lt;img src="<span class="hljs-subst">${product.image}</span>" alt="<span class="hljs-subst">${product.name}</span>" /&gt;
                &lt;h3&gt;<span class="hljs-subst">${product.name}</span>&lt;/h3&gt;
                &lt;p class="price"&gt;$<span class="hljs-subst">${product.price}</span>&lt;/p&gt;
                &lt;button class="add-to-cart"&gt;Add to Cart&lt;/button&gt;
            &lt;/div&gt;
        `</span>;

        <span class="hljs-built_in">this</span>.querySelector(<span class="hljs-string">'.add-to-cart'</span>).addEventListener(<span class="hljs-string">'click'</span>, <span class="hljs-function">() =&gt;</span> {
            <span class="hljs-built_in">this</span>.dispatchEvent(<span class="hljs-keyword">new</span> CustomEvent(<span class="hljs-string">'add-to-cart'</span>, {
                <span class="hljs-attr">detail</span>: product
            }));
        });
    }
}

<span class="hljs-comment">// Register the component</span>
customElements.define(<span class="hljs-string">'product-card'</span>, ProductCard);
</code></pre>
<p><strong>Usage:</strong></p>
<pre><code class="lang-html"><span class="hljs-tag">&lt;<span class="hljs-name">product-card</span> <span class="hljs-attr">product</span>=<span class="hljs-string">'{"name":"Phone","price":299,"image":"..."}'</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">product-card</span>&gt;</span>

<span class="hljs-tag">&lt;<span class="hljs-name">script</span>&gt;</span><span class="javascript">
    <span class="hljs-built_in">document</span>.querySelector(<span class="hljs-string">'product-card'</span>)
        .addEventListener(<span class="hljs-string">'add-to-cart'</span>, <span class="hljs-function">(<span class="hljs-params">e</span>) =&gt;</span> {
            <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'Added:'</span>, e.detail);
        });
</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
</code></pre>
<h3 id="heading-what-are-web-components">What Are Web Components?</h3>
<p><strong>Web Components</strong> are a set of web platform APIs that let you create new custom, reusable, and encapsulated HTML tags for use in web pages and web applications.</p>
<p>They're based on existing web standards and work across modern browsers, offering a way to extend HTML with new elements that have custom behavior and encapsulated styling.</p>
<p>Key features:</p>
<ul>
<li><p><strong>Custom HTML tags</strong> like <code>&lt;product-card&gt;</code></p>
</li>
<li><p><strong>Shadow DOM</strong> for style encapsulation (styles don't leak out, outside styles don't leak in)</p>
</li>
<li><p><strong>Works everywhere</strong>: React, Vue, Angular, or vanilla JS</p>
</li>
<li><p><strong>Zero dependencies</strong> (browser built-in)</p>
</li>
<li><p><strong>Perfect for design systems &amp; micro-frontends</strong></p>
</li>
</ul>
<h3 id="heading-the-advantage">The Advantage</h3>
<p><strong>React components only work in React.</strong></p>
<p><strong>Web Components work everywhere.</strong></p>
<p>Let's say you're building a design system for a company that has multiple teams using different frameworks. Team A uses React. Team B uses Vue. Team C uses Angular.</p>
<p>If you build components in React, you need to maintain three separate versions. Every time you update a button style, you update it in three places.</p>
<p>If you build with Web Components, you write it once. All teams import the same component. When React 19 comes out with breaking changes, your Web Components still work. When Vue 4 launches, your components still work.</p>
<p>You're not locked to any framework's release schedule.</p>
<hr />
<h2 id="heading-feature-showdown-client-side-routing">Feature Showdown: Client-Side Routing</h2>
<p>Frameworks like react-router, vue-router, and angular-router make routing seem like magic. But what are they actually doing?</p>
<h3 id="heading-how-framework-routers-work">How Framework Routers Work</h3>
<ol>
<li><p><strong>Listen for the</strong> <code>popstate</code> event (fires when URL changes via back/forward buttons)</p>
</li>
<li><p><strong>Check the routes list</strong> (map of paths to components)</p>
</li>
<li><p><strong>Render the correct component</strong> for the current path</p>
</li>
<li><p><strong>Cleanup and wait</strong> for the next route change</p>
</li>
</ol>
<p>That's it. The "magic" is just event listeners and conditional rendering.</p>
<h3 id="heading-how-to-implement-your-own-router">How to Implement Your Own Router</h3>
<pre><code class="lang-javascript"><span class="hljs-comment">// 1. Define routes</span>
<span class="hljs-keyword">const</span> routes = {
    <span class="hljs-string">'/'</span>: renderProductsPage,
    <span class="hljs-string">'/cart'</span>: renderCartPage,
};

<span class="hljs-comment">// 2. Router handler</span>
<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">router</span>(<span class="hljs-params"></span>) </span>{
    <span class="hljs-keyword">const</span> path = <span class="hljs-built_in">window</span>.location.pathname;
    <span class="hljs-keyword">const</span> render = routes[path] || render404;
    render();
}

<span class="hljs-comment">// 3. Navigate utility</span>
<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">navigate</span>(<span class="hljs-params">path</span>) </span>{
    history.pushState(<span class="hljs-literal">null</span>, <span class="hljs-literal">null</span>, path);
    router();
}

<span class="hljs-comment">// 4. Handle back/forward</span>
<span class="hljs-built_in">window</span>.addEventListener(<span class="hljs-string">'popstate'</span>, router);

<span class="hljs-comment">// 5. Intercept clicks</span>
<span class="hljs-built_in">document</span>.addEventListener(<span class="hljs-string">'click'</span>, <span class="hljs-function">(<span class="hljs-params">e</span>) =&gt;</span> {
    <span class="hljs-keyword">if</span> (e.target.matches(<span class="hljs-string">'a[href^="/"]'</span>)) {
        e.preventDefault();
        navigate(e.target.getAttribute(<span class="hljs-string">'href'</span>));
    }
});

<span class="hljs-comment">// Initial render</span>
router();
</code></pre>
<p><strong>What happens:</strong></p>
<ol>
<li><p><strong>Routes definition</strong>: Map paths to render functions</p>
</li>
<li><p><strong>Router handler</strong>: Looks at current URL, calls appropriate render function</p>
</li>
<li><p><strong>Navigate utility</strong>: Programmatically change routes via <code>history.pushState()</code></p>
</li>
<li><p><strong>popstate listener</strong>: Handles back/forward button clicks</p>
</li>
<li><p><strong>Click interceptor</strong>: Catches link clicks and uses <code>navigate()</code> instead of full page reload</p>
</li>
</ol>
<p>You've just built client-side routing in ~20 lines.</p>
<h3 id="heading-why-its-called-client-side">Why It's Called "Client-Side"</h3>
<p>Routing used to be a <em>server</em> thing. If you visited <code>/products</code>, the server returned a products page. If you visited <code>/cart</code>, the server returned a cart page. Every route change = full page reload.</p>
<p>Client-side routing moves this to the browser. The server sends all the JavaScript once. Then the JavaScript handles routing without talking to the server again.</p>
<p>That's why apps using react-router feel fast—no page reloads, just JavaScript swapping content.</p>
<hr />
<h2 id="heading-what-you-gain-by-going-vanilla">What You Gain By Going Vanilla</h2>
<p>When you deeply understand vanilla JavaScript, you gain five things:</p>
<h3 id="heading-1-deep-platform-understanding">1. Deep Platform Understanding</h3>
<p>You know <em>how</em> things actually work. You understand <code>getElementById</code> is faster than <code>querySelector</code>. You know which DOM APIs are O(n) vs O(1). You understand browser repaints and reflows.</p>
<p>This makes you a better developer <em>even when using frameworks</em> because you understand what's happening under the abstractions.</p>
<h3 id="heading-2-zero-framework-churn-framework-lock-in">2. Zero Framework Churn / Framework Lock-In</h3>
<p>No anxiety about React 19 breaking changes. No scrambling when a framework is deprecated. Your skills transfer across tools because you understand the foundation.</p>
<h3 id="heading-3-performance-by-default">3. Performance by Default</h3>
<p>The browser is already optimized for JavaScript and DOM manipulation. You get fast performance without needing to "optimize your bundle" or "code-split properly."</p>
<h3 id="heading-4-more-control">4. More Control</h3>
<p>You decide exactly what happens, when, and how. No framework making decisions for you.</p>
<h3 id="heading-5-future-proof-work-and-yourself">5. Future-Proof (Work AND Yourself)</h3>
<p>Your code will keep working. Browsers are backward-compatible. Code you write today in vanilla JS will work in browsers 10 years from now.</p>
<p>And <em>you</em> are future-proof. In an AI world where tools can generate boilerplate React code, your deep understanding of fundamentals, memory leaks, event loops, DOM performance, reactivity patterns, is what makes you valuable.</p>
<hr />
<h2 id="heading-the-point-because-we-need-to-say-it-clearly">The Point (Because We Need to Say It Clearly)</h2>
<p>If you take away three things from this article, let them be:</p>
<h3 id="heading-1-understand-the-whole-spectrum">1. Understand the Whole Spectrum</h3>
<p>Don't just learn React. Learn JavaScript deeply. Understand what React does under the hood. Know when frameworks add value and when they add bloat.</p>
<h3 id="heading-2-choose-based-on-project-needs">2. Choose Based on Project Needs</h3>
<p>Not every project needs React. A landing page with three buttons? Vanilla JS. A complex dashboard with real-time data and 50 interactive components? React makes sense.</p>
<h3 id="heading-3-dont-default-to-react-for-everything">3. Don't Default to React for Everything</h3>
<p>Break the reflex. When starting a new project, ask: "Do I actually need a framework for this?" Sometimes the answer is yes. But often, it's no.</p>
<hr />
<h2 id="heading-when-to-actually-use-vanilla-js">When to Actually Use Vanilla JS</h2>
<p>Here are five scenarios where vanilla JavaScript is the right choice:</p>
<h3 id="heading-1-smalllow-complexity-projects">1. Small/Low-Complexity Projects</h3>
<p>If your project is under 10-20 components or has minimal interactivity, vanilla JS will be faster to write, faster to load, and easier to maintain.</p>
<p><strong>Example:</strong> A personal portfolio site, a documentation page, a simple calculator.</p>
<h3 id="heading-2-performance-optimization-matters">2. Performance Optimization Matters</h3>
<p>If you're building for users on slow networks or expensive data plans (hello, Nigeria), every kilobyte matters. Vanilla JS has zero overhead.</p>
<p><strong>Example:</strong> A mobile app for emerging markets, an offline-first PWA.</p>
<h3 id="heading-3-learning-fundamentals">3. Learning Fundamentals</h3>
<p>If you're still grasping how JavaScript works, build projects in vanilla first. Understand closures, scope, async/await, DOM manipulation, event delegation. <em>Then</em> learn React.</p>
<p>You'll appreciate what frameworks do because you'll know what problems they solve.</p>
<h3 id="heading-4-you-need-more-control">4. You Need More Control</h3>
<p>Sometimes you need precise control over rendering timing, event handling, or memory management. Frameworks make decisions for you. Vanilla JS lets you decide.</p>
<p><strong>Example:</strong> A data visualization library, a game engine, performance-critical animations.</p>
<h3 id="heading-5-speed-for-light-projects">5. Speed for Light Projects</h3>
<p>For throwaway prototypes, internal tools, or quick demos, vanilla JS is faster. No <code>npm install</code>, no build step, no framework decisions. Just code.</p>
<hr />
<h2 id="heading-the-nigerian-context-why-this-really-matters">The Nigerian Context: Why This REALLY Matters</h2>
<p>Let me bring this home.</p>
<p>In Nigeria, where:</p>
<ul>
<li><p><strong>Data costs money</strong> (1GB can cost ₦500-1000, and many people are on prepaid)</p>
</li>
<li><p><strong>Internet can be slow</strong> (3G is common, 4G is unreliable, 5G is rare)</p>
</li>
<li><p><strong>Users are patient only so long</strong> (if your app doesn't load in 5 seconds, they'll switch to a competitor)</p>
</li>
</ul>
<p><strong>Every kilobyte matters.</strong></p>
<p>When you ship a React app that's 2MB after "optimization," you're asking Nigerian users to:</p>
<ol>
<li><p>Pay for that data</p>
</li>
<li><p>Wait for it to download over a slow connection</p>
</li>
<li><p>Hope their connection doesn't drop mid-download</p>
</li>
</ol>
<p>This isn't hypothetical. I've watched people close apps because "this one is not working" when really it's just... still loading.</p>
<p>A vanilla JS app that's 50KB loads fast, costs less data, and works on terrible connections.</p>
<p><strong>Choose wisely.</strong></p>
<p>Your technical choices have real-world consequences for real people. A smaller bundle isn't just a performance metric—it's respect for your users' constraints.</p>
<hr />
<h2 id="heading-final-thoughts-the-balanced-take">Final Thoughts: The Balanced Take</h2>
<p>Let's bring this full circle with five closing points:</p>
<h3 id="heading-1-frameworks-exist-for-good-reasons">1. Frameworks Exist for Good Reasons</h3>
<p>React, Vue, and Angular solved real problems: cross-browser compatibility, manual DOM updates, state management chaos, component reusability. They earned their dominance.</p>
<h3 id="heading-2-but-theyre-not-always-the-answer">2. But They're Not Always the Answer</h3>
<p>For small projects, simple interactions, or performance-critical apps, frameworks are often overkill. The overhead isn't worth it.</p>
<h3 id="heading-3-javascript-is-powerful-on-its-own">3. JavaScript is Powerful on Its Own</h3>
<p>With Proxies, Web Components, and modern DOM APIs, vanilla JavaScript can do <em>a lot</em>. You can build reactivity, routing, and components without frameworks.</p>
<h3 id="heading-4-small-bundles-happy-users-successful-businesses">4. Small Bundles = Happy Users = Successful Businesses</h3>
<p>Users on slow networks or expensive data plans will love you for keeping your app lightweight. Happy users stick around. Happy users tell friends. Happy users convert.</p>
<h3 id="heading-5-understanding-fundamentals-makes-you-a-better-developer">5. Understanding Fundamentals Makes You a Better Developer</h3>
<p>Whether you end up using React or vanilla JS, understanding <em>how things actually work</em> makes you more effective. You debug faster. You optimize better. You make smarter architectural decisions.</p>
<p>In an era where AI can generate boilerplate framework code, your deep understanding of fundamentals is what makes you irreplaceable.</p>
<hr />
<h2 id="heading-skilling-up-where-to-learn-more">Skilling Up: Where to Learn More</h2>
<p>Want to dive deeper into vanilla JavaScript? Here are the best resources:</p>
<p><strong>Books &amp; Websites:</strong></p>
<ul>
<li><p><strong>O'Reilly Books</strong>: "JavaScript: The Definitive Guide," "You Don't Know JS" series</p>
</li>
<li><p><strong>Web.dev</strong>: Google's web development resource with excellent JavaScript guides</p>
</li>
<li><p><strong>Mozilla Web Docs (MDN)</strong>: The definitive reference for JavaScript, DOM APIs, Web Components</p>
</li>
</ul>
<p><strong>Courses:</strong></p>
<ul>
<li><strong>Frontend Masters</strong>: Beginner to advanced JavaScript courses, including deep dives into Proxies, Shadow DOM, and Web APIs</li>
</ul>
<p><strong>Topics to Master:</strong></p>
<ul>
<li><p>JavaScript Proxies</p>
</li>
<li><p>Shadow DOM</p>
</li>
<li><p>Web Components</p>
</li>
<li><p>DOM APIs (and their performance characteristics)</p>
</li>
<li><p>Event delegation</p>
</li>
<li><p>Memory management and garbage collection</p>
</li>
</ul>
<hr />
<h2 id="heading-conclusion-the-real-framework-is-understanding">Conclusion: The Real Framework is Understanding</h2>
<p>I started learning to code by accident. I had an itel phone, installed a random app, and started writing HTML without even knowing it was code. I built a CBT app using link tags and thousands of <code>&lt;br&gt;</code> tags to "jump" between questions.</p>
<p>It was ridiculous. It was clever. It <em>worked</em>.</p>
<p>When I finally learned JavaScript, I didn't start with React. I used <code>alert()</code>, <code>confirm()</code>, and <code>querySelector</code>. Every line of code taught me something about <em>how the web works</em>.</p>
<p>That foundation, understanding the platform deeply, is what makes me effective today. Whether I'm writing React, Angular, or vanilla JavaScript, I know what's happening under the hood.</p>
<p>The best framework isn't React or Vue or Angular.</p>
<p><strong>The best framework is understanding.</strong></p>
<p>So before you type <code>npx create-react-app</code> for your next project, pause. Ask yourself: <em>Do I actually need this?</em></p>
<p>Sometimes the answer is yes. But often, more often than we admit, the answer is no.</p>
<p>And in those moments, vanilla JavaScript is enough.</p>
<p>**Choose wisely. Every kilobyte matters.**Resources from the Talk</p>
<p><strong>Slides:</strong> You can view the original presentation slides from DevFest Ibadan <a target="_blank" href="https://docs.google.com/presentation/d/1kEBwyRp0gRxhitfAjBVd5q5TiXqUu_fVQXAb-pydMxc/edit?slide=id.g2efc3e78840_3_10#slide=id.g2efc3e78840_3_10">here</a></p>
<hr />
]]></content:encoded></item><item><title><![CDATA[Building a Reusable AI SDK]]></title><description><![CDATA[Recently, I’ve found myself building AI enabled applications, like the AI content optimization platform “https://buffbyteai.xyz/” and a Anaemia detection using computer models https://nailtechapp.netlify.app/, some of my major problems is managing th...]]></description><link>https://crackedchefs.devferanmi.xyz/building-a-reusable-ai-sdk</link><guid isPermaLink="true">https://crackedchefs.devferanmi.xyz/building-a-reusable-ai-sdk</guid><category><![CDATA[AI]]></category><category><![CDATA[sdk]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Oluwaferanmi Adeniji]]></dc:creator><pubDate>Sun, 14 Sep 2025 20:40:00 GMT</pubDate><content:encoded><![CDATA[<p>Recently, I’ve found myself building AI enabled applications, like the AI content optimization platform “<a target="_blank" href="https://buffbyteai.xyz/">https://buffbyteai.xyz/</a>” and a Anaemia detection using computer models <a target="_blank" href="https://nailtechapp.netlify.app/">https://nailtechapp.netlify.app/</a>, some of my major problems is managing the api keys for my LLM providers, dynamic prompts, variable prefill, AI persona’s, retry/failover mechanisms, response structure validation (structure, type, defaults etc)</p>
<p>Imagine if it’s easy for me to use the same key accross different apps, while still being able to dynamically switch providers in a split second, and validate input and output from the LLMs used, this will make my development of AI-enabled applications super fast!.</p>
<p>The pain points are real:</p>
<ul>
<li><p>Repetitive setup across projects</p>
</li>
<li><p>Inconsistent error handling patterns</p>
</li>
<li><p>Manual JSON validation and parsing</p>
</li>
<li><p>API key management across environments</p>
</li>
<li><p>No standardized way to handle retries and failures</p>
</li>
<li><p>Provider-specific code that's hard to switch</p>
</li>
</ul>
<h2 id="heading-initial-thoughts">Initial Thoughts</h2>
<p>The solution seemed obvious: build a unified SDK that abstracts away provider differences while offering consistent developer experience. But the challenge was balancing simplicity with flexibility. Too simple, and it becomes limiting. Too complex, and it defeats the purpose of reducing boilerplate.</p>
<p>I wanted something that felt natural to use, a fluent API that could handle the 80% use case elegantly while still allowing customization for edge cases. The key insight was that most AI interactions follow the same pattern: authenticate, send prompt with variables, get structured response, handle errors.</p>
<h2 id="heading-the-solution-architecture">The Solution Architecture</h2>
<p>The SDK centers around two main methods:</p>
<p><strong>Initialize once, use everywhere:</strong></p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> ai = sdk.initialize({
  <span class="hljs-attr">auth</span>: {
    <span class="hljs-attr">type</span>: <span class="hljs-string">"embedded"</span> | <span class="hljs-string">"fetch"</span>,
    <span class="hljs-attr">url</span>: <span class="hljs-string">"https://my-keys-api.com/keys"</span>,
    <span class="hljs-attr">keys</span>: { <span class="hljs-attr">claude</span>: <span class="hljs-string">"key1"</span>, <span class="hljs-attr">openai</span>: <span class="hljs-string">"key2"</span> }
  },
  <span class="hljs-attr">settings</span>: {
    <span class="hljs-attr">retryOnFail</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">maxRetry</span>: <span class="hljs-number">3</span>,
    <span class="hljs-attr">cachable</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">trimPrompt</span>: <span class="hljs-literal">false</span>
  }
})
</code></pre>
<p><strong>Prompt with intelligence:</strong></p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> result = <span class="hljs-keyword">await</span> ai.prompt(<span class="hljs-string">'Get weather in {{CITY}}'</span>, {
  <span class="hljs-attr">expectJson</span>: <span class="hljs-literal">true</span>,
  <span class="hljs-attr">jsonStructure</span>: { <span class="hljs-attr">temp</span>: <span class="hljs-string">"number"</span>, <span class="hljs-attr">condition</span>: <span class="hljs-string">"string"</span> },
  <span class="hljs-attr">validateJSON</span>: <span class="hljs-literal">true</span>,
  <span class="hljs-attr">errorOnInvalidJSON</span>: <span class="hljs-literal">true</span>
}, { <span class="hljs-attr">CITY</span>: <span class="hljs-string">"San Francisco"</span> })
</code></pre>
<p>The authentication system supports both embedded keys and remote fetching, solving the multi-environment deployment challenge. Variable interpolation with required validation prevents runtime errors. JSON structure validation ensures type safety with AI responses.</p>
<h2 id="heading-decision-matrix">Decision Matrix</h2>
<p>When designing the feature set, I evaluated each potential feature against three criteria:</p>
<p><strong>Impact</strong> (how much pain it solves),</p>
<p><strong>Complexity</strong> (implementation difficulty), and</p>
<p><strong>Usage Frequency</strong> (how often developers need it).  </p>
<p><strong>High Impact + Low Complexity + High Usage → V1</strong></p>
<ul>
<li><p>Variable interpolation and validation</p>
</li>
<li><p>Multi-provider authentication</p>
</li>
<li><p>JSON response handling and validation</p>
</li>
<li><p>Basic retry logic and caching</p>
</li>
</ul>
<p><strong>High Impact + High Complexity → V2</strong></p>
<ul>
<li><p>Streaming responses (complex WebSocket/SSE handling)</p>
</li>
<li><p>Usage tracking and cost estimation (requires pricing data maintenance)</p>
</li>
</ul>
<p><strong>Medium Impact + Medium Complexity → V2</strong></p>
<ul>
<li><p>Template management system</p>
</li>
<li><p>Advanced middleware and hooks</p>
</li>
</ul>
<p><strong>Low Impact or Very High Complexity → Future/Never</strong></p>
<ul>
<li><p>Complex prompt engineering features</p>
</li>
<li><p>Built-in fine-tuning capabilities</p>
</li>
</ul>
<p>This matrix helped avoid feature creep while ensuring V1 addresses the most painful developer problems.</p>
<h2 id="heading-version-1-core-foundation">Version 1: Core Foundation</h2>
<p>V1 focuses on the essential developer experience:</p>
<p><strong>Authentication &amp; Configuration</strong></p>
<ul>
<li><p>Multi-provider support (Claude, OpenAI, extensible to others)</p>
</li>
<li><p>Flexible auth: embedded keys or remote URL fetching</p>
</li>
<li><p>Global settings with per-request overrides</p>
</li>
</ul>
<p><strong>Smart Prompting</strong></p>
<ul>
<li><p><code>{{VARIABLE}}</code> interpolation with validation</p>
</li>
<li><p>Required variable checking (throws helpful errors)</p>
</li>
<li><p>Clean variable passing via options object</p>
</li>
</ul>
<p><strong>Response Handling</strong></p>
<ul>
<li><p>Automatic JSON parsing and validation</p>
</li>
<li><p>Type-safe structure checking against expected schema</p>
</li>
<li><p>Configurable error handling for invalid responses</p>
</li>
</ul>
<p><strong>Reliability</strong></p>
<ul>
<li><p>Retry logic for failed requests</p>
</li>
<li><p>Response caching to reduce API calls</p>
</li>
<li><p>Prompt trimming for token optimization</p>
</li>
</ul>
<p>This gives developers a production-ready foundation that eliminates most AI integration boilerplate while maintaining flexibility.</p>
<h2 id="heading-version-2-advanced-features">Version 2: Advanced Features</h2>
<p>Once V1 proves the core concept, V2 will add sophistication:</p>
<p><strong>Streaming Support</strong> Real-time response streaming for better user experience in chat applications and long-form content generation.</p>
<p><strong>Template Management</strong><br />Reusable prompt templates with versioning, making it easier to maintain and iterate on prompts across teams.</p>
<p><strong>Usage Analytics</strong> Token tracking, cost estimation, and usage limits to help manage AI budgets and optimize performance.</p>
<p><strong>Advanced Middleware</strong> Hooks for logging, analytics, custom validation, and request modification, enabling complex workflow integration.</p>
<h2 id="heading-why-this-approach-works">Why This Approach Works</h2>
<p>This SDK design succeeds because it follows proven software engineering principles:</p>
<p><strong>Progressive Enhancement</strong>: Start simple, add complexity gradually based on real usage patterns.</p>
<p><strong>Separation of Concerns</strong>: Authentication, prompting, and response handling are cleanly separated but work together seamlessly.</p>
<p><strong>Developer Experience First</strong>: The API feels natural and reduces cognitive load rather than adding abstraction complexity.</p>
<p><strong>Flexibility Without Bloat</strong>: Core functionality handles most use cases, while extension points allow customization without forcing complexity on simple users.</p>
<p>I have chosen the name “Ajala AI SDK”, Ajala stood out to me because of the legendary <strong>“Ajala, THE TRAVELER”</strong> who travelled the entire world on a bicycle, hoping to do that someday, but definitely not with a bicycle 😂😂😂 , <strong>Ajala AI SDK</strong> in the open, you can follow the development on <a target="_blank" href="https://github.com/spiderocious/ajala-ai-sdk">github</a>. Star the repo to stay updated, open issues for features you need, or reach out if you'd like to contribute code, documentation, or ideas.<br />Building developer tools is always better as a community effort!</p>
<p><strong>Bye.</strong></p>
]]></content:encoded></item><item><title><![CDATA[Accessibility as the Bare Minimum: Frontend Engineer's Guide]]></title><description><![CDATA[Hey there, fellow Coding Chefs! 👋
You've shipped a beautiful product. The animations are smooth, the gradients are great, the Lighthouse performance score is a proud 94. Marketing is happy. Your PM i]]></description><link>https://crackedchefs.devferanmi.xyz/accessibility-as-the-bare-minimum-frontend-engineer-s-guide</link><guid isPermaLink="true">https://crackedchefs.devferanmi.xyz/accessibility-as-the-bare-minimum-frontend-engineer-s-guide</guid><category><![CDATA[a11y]]></category><category><![CDATA[Accessibility]]></category><category><![CDATA[frontend]]></category><category><![CDATA[Web Accessibility]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Contrast]]></category><category><![CDATA[performance]]></category><category><![CDATA[Developer Tools]]></category><category><![CDATA[#WCAG]]></category><dc:creator><![CDATA[Oluwaferanmi Adeniji]]></dc:creator><pubDate>Wed, 23 Apr 2025 09:30:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/5d5009ed2db7c7fb3cd7cf28/2e40e35e-5741-441a-a02b-17f8ff69e719.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Hey there, fellow Coding Chefs! 👋</p>
<p>You've shipped a beautiful product. The animations are smooth, the gradients are great, the Lighthouse performance score is a proud 94. Marketing is happy. Your PM is already talking about the launch tweet. You close your laptop feeling like a rockstar.</p>
<p>Then Monday morning, someone on your team pulls up the same site on a Tecno phone, outside, in the middle of a busy market. The sun is doing what a hot sun does. They squint. They tap. The button they need is there, technically, but the text on it is a soft grey on a slightly-softer grey, and between the dust, the glare, and the trader calling them back to the stall they were buying tomatoes from, they give up and close the tab.</p>
<p>Your app didn't crash. It didn't throw an error. It just quietly failed a real person.</p>
<p>As engineers who work on products that serve a wide variety of users, we're often blinded by our own context. We build on a 27-inch monitor in an conditioned environment. We test in Chrome DevTools. We assume the user is sitting still, with both hands free, looking at a 100% zoomed screen, on fibre internet, with perfect eyesight and no distractions.</p>
<p>But your user is somewhere else entirely.</p>
<p>They're in the crowded aisles of Tewure market in Ogbomoso, trying to pay a vendor before the queue behind them becomes an angry chorus. They're in the organised, air-conditioned comfort of a mall in Lekki, but they're holding a baby in one arm and a card in the other, trying to complete a checkout with one thumb. They're at a party in a Victoria Island club at 1AM, under strobing RGB lights, trying to split a bill with friends on the app you built. They're in a Danfo bus at 6PM, standing, hanging off a strap with one hand, squinting at the phone in the other.</p>
<p>And they all need your thing to work. Fast. In fewer clicks. With text they can actually read.</p>
<p>This is what accessibility really is. It's not a compliance checklist. It's not something you "add at the end." It's the floor you build everything else on top of. The minimum. The bare minimum.</p>
<p>Let's get cracking (or cooking),</p>
<hr />
<h2>Why This Matters (Beyond the Obvious)</h2>
<p>Most engineers I've met hear "accessibility" and their brain immediately jumps to one image: a blind user with a screen reader. And yes, that user exists, that user matters, and we'll get to them. But that mental model is what makes a11y feel like a niche concern, a nice-to-have, something for the 1%.</p>
<p>It isn't.</p>
<p>Here's the mental shift I want you to make: <strong>stop thinking about "disabled users." Start thinking about constraints.</strong></p>
<p>Constraints come in three flavours:</p>
<ul>
<li><p><strong>Permanent</strong> — a user who is blind, or has low vision, or motor impairment, or is colorblind.</p>
</li>
<li><p><strong>Temporary</strong> — a developer who just had LASIK and can't see the screen clearly for a week. A friend with a broken wrist who's typing one-handed.</p>
</li>
<li><p><strong>Situational</strong> — and this is the big one. Every single person is situationally constrained all the time.</p>
</li>
</ul>
<p>Bright sunlight is a visual impairment. A screaming toddler is a cognitive impairment. One-handed scrolling on a Danfo bus is a motor impairment. Trying to read a receipt under RGB strobe lights at Quilox is a visual impairment. A terrible 3G signal in a rural area is a bandwidth impairment that makes your fancy fonts not load, exposing whatever fallback stack you didn't think about.</p>
<p>Every pattern that helps the "permanent" user also helps the "situational" one. The blind user's screen reader announces your button text — and the sighted user in bright sunlight who can only read high-contrast text benefits from the exact same semantic markup that made the announcement possible.</p>
<p>Accessibility is universal design. When you build for the edges, you make the middle better for free.</p>
<p>I had to learn this the hard way.</p>
<p>I once shipped a modal on a project. It was stunning. Soft blurred backdrop, gentle 200ms fade-in, a close button in the corner that was just an × character in a light grey. I was proud of it. Design loved it. It passed every code review.</p>
<p>A month later, a user emailed support saying he couldn't close the modal. Couldn't click the X. Turned out he was on an Android phone, at work, in a warehouse with terrible fluorescent lighting, and he literally could not see the close button. The grey was too light. The touch target was too small. And because I'd built the modal with a <code>&lt;div&gt;</code> (more on that sin in a moment), the Esc key did nothing.</p>
<p>The fix took me 15 minutes. The damage, a user who walked away from a feature we spent three weeks building, was much longer.</p>
<p><strong>Accessibility isn't a feature you add. It's a floor you build on.</strong></p>
<hr />
<h2>Semantic HTML: The Foundation Everyone Skips</h2>
<p>If you learn nothing else from this article, learn this: <strong>use the right HTML tag.</strong></p>
<p>Most accessibility problems I've seen in the wild aren't solved with ARIA attributes, or screen reader testing, or expensive tooling. They're solved by replacing a <code>&lt;div&gt;</code> with a <code>&lt;button&gt;</code>.</p>
<p>Here's the original sin of modern frontend:</p>
<pre><code class="language-jsx">// Please don't do this
&lt;div className="btn" onClick={handleSubmit}&gt;
  Submit
&lt;/div&gt;
</code></pre>
<p>This looks like a button. It behaves like a button if you're a sighted user with a mouse. But:</p>
<ul>
<li><p>It's not focusable with the Tab key</p>
</li>
<li><p>It doesn't trigger on Enter or Space</p>
</li>
<li><p>A screen reader announces it as "Submit" (just text, not a button)</p>
</li>
<li><p>It doesn't get the browser's default button semantics</p>
</li>
<li><p>You now have to reimplement all of that by hand</p>
</li>
</ul>
<p>The fix:</p>
<pre><code class="language-jsx">&lt;button type="button" onClick={handleSubmit}&gt;
  Submit
&lt;/button&gt;
</code></pre>
<p>One tag change. You get keyboard focus, Enter and Space activation, screen reader announcement as "Submit, button," disabled-state handling, and form integration — all for free. Free. The browser was always going to give you this. You just refused to accept it.</p>
<p>Here's your cheat sheet of swaps:</p>
<ul>
<li><p>Use <code>&lt;button&gt;</code> for anything that performs an action. Not <code>&lt;div&gt;</code>. Not <code>&lt;span&gt;</code>. Not <code>&lt;a&gt;</code> without an <code>href</code>.</p>
</li>
<li><p>Use <code>&lt;a href="..."&gt;</code> for anything that navigates. Not <code>&lt;div onClick={navigate}&gt;</code>.</p>
</li>
<li><p>Use <code>&lt;input&gt;</code> and <code>&lt;label&gt;</code> for form fields. Always paired. Always.</p>
</li>
<li><p>Use <code>&lt;nav&gt;</code>, <code>&lt;main&gt;</code>, <code>&lt;header&gt;</code>, <code>&lt;footer&gt;</code>, <code>&lt;aside&gt;</code>, <code>&lt;article&gt;</code> to structure the page. Screen readers let users skip straight to these landmarks.</p>
</li>
<li><p>Use <code>&lt;h1&gt;</code> through <code>&lt;h6&gt;</code> in order. Don't pick <code>&lt;h3&gt;</code> because you like how it looks — style the <code>&lt;h1&gt;</code>.</p>
</li>
</ul>
<p>If you catch yourself typing <code>&lt;div role="button"&gt;</code>, stop, delete that, and type <code>&lt;button&gt;</code>. If you find yourself typing <code>&lt;div role="navigation"&gt;</code>, delete that, and type <code>&lt;nav&gt;</code>. The tag already exists. Use it.</p>
<hr />
<h2>Keyboard Navigation: The Test That Catches 60% of Your Issues</h2>
<p>Here's the cheapest, most humbling test you can run on your own website. Ready?</p>
<p><strong>Unplug your mouse. Put your trackpad hand behind your back. Try to use your app with only the keyboard.</strong></p>
<p>Tab, Shift+Tab, Enter, Space, Escape, arrow keys. That's it. That's your whole toolkit now.</p>
<p>If you can't get from the homepage to completing a purchase using only those keys, congratulations, you've just found out what your screen-reader users feel every day.</p>
<p>Here's what usually breaks:</p>
<ul>
<li><p><strong>Focus disappears.</strong> You Tab into a modal and suddenly have no idea where the focus ring is. Someone removed the default blue outline with <code>*:focus { outline: none }</code> because it "looked ugly" and forgot to add one back.</p>
</li>
<li><p><strong>Focus escapes modals.</strong> You open a dropdown or a dialog and Tab takes you to an element behind it. Focus should be trapped inside until the modal closes.</p>
</li>
<li><p><strong>Tab order is chaos.</strong> Your visually-linear form jumps from the first name field to the footer to the third step because someone used <code>position: absolute</code> and CSS grid to rearrange the DOM.</p>
</li>
<li><p><strong>Custom controls are dead.</strong> That slick custom dropdown you built with <code>&lt;div&gt;</code>s? Tab skips right over it. Or worse, lands on it and does nothing.</p>
</li>
</ul>
<p>The fixes, in order of effort:</p>
<p><strong>1. Never remove focus styles without replacing them.</strong></p>
<pre><code class="language-css">/* The war crime */
*:focus { outline: none; }

/* The minimum acceptable replacement */
*:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}
</code></pre>
<p>Use <code>:focus-visible</code> instead of <code>:focus</code> so the ring only shows up for keyboard users, not mouse clicks. Best of both worlds.</p>
<p><strong>2. Trap focus inside modals.</strong></p>
<p>When a modal opens, focus should move into it and stay there until it closes. Here's the minimum vanilla version:</p>
<pre><code class="language-javascript">function trapFocus(modalElement) {
  const focusable = modalElement.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const first = focusable[0];
  const last = focusable[focusable.length - 1];

  modalElement.addEventListener('keydown', (e) =&gt; {
    if (e.key !== 'Tab') return;
    if (e.shiftKey &amp;&amp; document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey &amp;&amp; document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  });

  first?.focus();
}
</code></pre>
<p>In React, just use a library — <code>react-focus-lock</code> or the built-in focus management in Radix UI, React Aria, or Headless UI. Don't roll your own unless you're writing a component library.</p>
<p><strong>3. Add a skip link.</strong></p>
<p>At the top of your page, invisible until focused:</p>
<pre><code class="language-html">&lt;a href="#main" class="skip-link"&gt;Skip to main content&lt;/a&gt;
</code></pre>
<p>Your keyboard users will thank you. Tab once, Enter, and they skip past your 40-item navigation menu.</p>
<p><strong>4. Make sure everything interactive is reachable.</strong></p>
<p>If your Tab key doesn't land on a custom control, something is wrong. Either use a real <code>&lt;button&gt;</code> or <code>&lt;a&gt;</code>, or you're going to need <code>tabindex="0"</code> plus a pile of ARIA and keyboard handlers — and at that point, please, please just use the semantic HTML.</p>
<p>The keyboard test takes 5 minutes and catches the majority of real accessibility bugs. Do it before every major feature ships.</p>
<hr />
<h2>ARIA: Useful, Dangerous, and Mostly Unnecessary</h2>
<p>ARIA stands for Accessible Rich Internet Applications. It's a set of attributes you can add to HTML to give extra semantic information to assistive technology. Things like <code>role="dialog"</code>, <code>aria-label="Close"</code>, <code>aria-expanded="true"</code>.</p>
<p>Here's the thing nobody tells juniors:</p>
<p><strong>The first rule of ARIA is: don't use ARIA.</strong></p>
<p>Or, more precisely: no ARIA is better than bad ARIA. And most ARIA is bad ARIA.</p>
<p>The reason is that ARIA doesn't <em>do</em> anything by itself. It doesn't add behaviour. It just tells screen readers what to say. If you add <code>role="button"</code> to a <code>&lt;div&gt;</code>, you've told the screen reader it's a button — but you haven't made it focusable, haven't made it respond to Enter or Space, haven't made it look like a button. You've just lied to the screen reader. The user will now try to press the button, nothing will happen, and they'll leave.</p>
<p>That's worse than no ARIA at all.</p>
<p>So the workflow is:</p>
<ol>
<li><p>Use the right semantic HTML. Can you use <code>&lt;button&gt;</code>? Use <code>&lt;button&gt;</code>.</p>
</li>
<li><p>If semantic HTML can't express what you need, then add ARIA.</p>
</li>
<li><p>If you add ARIA, make sure you've also added the behaviour it implies.</p>
</li>
</ol>
<p>When do you actually need ARIA?</p>
<p><strong>Live regions</strong> for dynamic updates the user should hear:</p>
<pre><code class="language-html">&lt;div aria-live="polite" aria-atomic="true"&gt;
  Your cart has 3 items.
&lt;/div&gt;
</code></pre>
<p>When the text inside changes, a screen reader announces it without the user having to navigate there. Useful for toast notifications, form errors, live search results.</p>
<p><strong>Labels for icon-only buttons:</strong></p>
<pre><code class="language-html">&lt;button aria-label="Close modal"&gt;
  &lt;svg&gt;...&lt;/svg&gt;
&lt;/button&gt;
</code></pre>
<p>Without the <code>aria-label</code>, a screen reader says "button." With it, "Close modal, button." Night and day.</p>
<p><strong>Expanded/collapsed state for custom disclosure widgets:</strong></p>
<pre><code class="language-html">&lt;button aria-expanded="false" aria-controls="menu-items"&gt;
  Menu
&lt;/button&gt;
&lt;ul id="menu-items" hidden&gt;
  ...
&lt;/ul&gt;
</code></pre>
<p>Update <code>aria-expanded</code> in JavaScript when the menu opens. Screen readers will announce the state change.</p>
<p>Paired example, because this is where people go wrong the most:</p>
<pre><code class="language-jsx">// Bad ARIA — lying to the screen reader
&lt;div role="button" onClick={handleClick}&gt;
  Save
&lt;/div&gt;

// No ARIA, just correct HTML — the right answer
&lt;button onClick={handleClick}&gt;
  Save
&lt;/button&gt;
</code></pre>
<p>You didn't need ARIA. You needed the tag that already exists.</p>
<hr />
<h2>Colour, Contrast, and "I Can't Read This Under the Sun"</h2>
<p>Remember our friend in a busy market? This section is for them.</p>
<p>WCAG (the accessibility standard) specifies minimum contrast ratios between text and background:</p>
<ul>
<li><p><strong>4.5:1</strong> for normal body text</p>
</li>
<li><p><strong>3:1</strong> for large text (18pt regular or 14pt bold and up)</p>
</li>
<li><p><strong>3:1</strong> for non-text elements like icons and form borders</p>
</li>
</ul>
<p>That means if your body text is <code>#888</code> on a <code>#FFF</code> background (a contrast of about 3.5:1), it fails. Looks nice on your Macbook in a dark office. Unreadable on a Tecno Spark in Ogbomoso at noon.</p>
<p>The test is not "does it look readable to me right now." The test is "does it meet the ratio, objectively."</p>
<p>Quick ways to check:</p>
<ul>
<li><p><strong>Chrome DevTools</strong> — hover over any text in the Elements panel, click the colour swatch, it shows you the contrast ratio and tells you whether it passes AA or AAA.</p>
</li>
<li><p><strong>axe DevTools</strong> extension — free, audits your whole page, flags contrast failures with line numbers.</p>
</li>
<li><p><strong>Lighthouse</strong> — built into Chrome, runs a full accessibility audit.</p>
</li>
</ul>
<p>A few more rules that come up a lot:</p>
<p><strong>Don't rely on colour alone to convey information.</strong> The classic sin:</p>
<pre><code class="language-html">&lt;!-- Bad: only red tells the user this is an error --&gt;
&lt;p style="color: red"&gt;Your email is invalid&lt;/p&gt;

&lt;!-- Better: colour AND an icon AND clear text --&gt;
&lt;p class="error"&gt;
  &lt;svg class="error-icon"&gt;...&lt;/svg&gt;
  Your email address is missing an @ symbol
&lt;/p&gt;
</code></pre>
<p>A colorblind user sees two identical grey paragraphs otherwise. A user in bright sunlight sees two identical washed-out paragraphs. The icon and the text do the heavy lifting; the colour is a bonus.</p>
<p><strong>Design for the dim environments too.</strong> Your user at the club party with RGB lights bouncing off the screen needs high-contrast, chunky text. Your user at 2AM in bed needs the opposite. Support dark mode. Support system font sizes. Don't fix the font size to 12px because it "looks more refined" — let the browser scale.</p>
<p><strong>Test at 200% zoom.</strong> Open your app, Cmd + a bunch of times, see if your layout breaks. It probably will. A non-trivial number of users browse at 150-200% zoom by default, and they are often the users with the most disposable income (they're older). Breaking the layout at zoom means losing them.</p>
<hr />
<h2>Forms: Where Accessibility Goes to Die</h2>
<p>Forms are the single highest-value accessibility target in any app. Every checkout, signup, login, and onboarding funnel is a form. If forms are inaccessible, the business loses money directly.</p>
<p>Here's the non-negotiable checklist.</p>
<p><strong>1. Every input needs a real label.</strong></p>
<p>Not a placeholder. A label.</p>
<pre><code class="language-html">&lt;!-- Wrong: placeholder as label --&gt;
&lt;input type="email" placeholder="Email address" /&gt;

&lt;!-- Right --&gt;
&lt;label for="email"&gt;Email address&lt;/label&gt;
&lt;input id="email" type="email" /&gt;
</code></pre>
<p>Placeholders disappear the moment the user starts typing. If they get distracted (and remember, they're in a Danfo, holding a baby, at a club, with screaming toddlers), they now have no idea what the field is for. Real labels stay.</p>
<p><strong>2. Errors must be announced, not just shown.</strong></p>
<pre><code class="language-html">&lt;label for="email"&gt;Email address&lt;/label&gt;
&lt;input
  id="email"
  type="email"
  aria-invalid="true"
  aria-describedby="email-error"
/&gt;
&lt;p id="email-error" role="alert"&gt;
  Please enter a valid email address
&lt;/p&gt;
</code></pre>
<p>The <code>aria-describedby</code> links the error to the input, so when the user focuses the field, their screen reader reads both the label AND the error. The <code>role="alert"</code> makes the error announce itself the moment it appears.</p>
<p><strong>3. Required fields need words, not just asterisks.</strong></p>
<pre><code class="language-html">&lt;!-- Incomplete --&gt;
&lt;label for="name"&gt;Name *&lt;/label&gt;

&lt;!-- Complete --&gt;
&lt;label for="name"&gt;Name &lt;span class="required"&gt;(required)&lt;/span&gt;&lt;/label&gt;
&lt;input id="name" required aria-required="true" /&gt;
</code></pre>
<p>A red asterisk alone is useless if you're colorblind and your screen reader doesn't announce the styling.</p>
<p><strong>4. Touch targets need to be big enough.</strong></p>
<p>Minimum 44x44 CSS pixels for anything tappable. Your user is in a Danfo, the bus just hit a pothole, their thumb is wide, and the checkout button is an 18px-tall sliver. They miss. They tap the "Delete account" button next to it instead. You just lost a customer and maybe a lawsuit.</p>
<p>Give things room.</p>
<p><strong>5. Don't trap users in broken states.</strong></p>
<p>If a user tries to submit a form and there are errors, don't just highlight the fields in red and leave them to guess. Scroll to the first error. Focus it. Announce it. Make it trivial for them to fix and retry.</p>
<hr />
<h2>Images, Media, and the Alt Text Problem</h2>
<p>Alt text rules are simpler than most people think:</p>
<ul>
<li><p><strong>Informational images</strong> (product photos, charts, diagrams) — describe what they convey. "A red Toyota Corolla 2020 model, front three-quarter view."</p>
</li>
<li><p><strong>Decorative images</strong> (background textures, divider illustrations) — empty alt: <code>alt=""</code>. Not missing. Empty. An empty alt tells the screen reader "skip this." A missing alt tells it "read out the filename," which is how your users end up hearing "IMG underscore 2847 dot J P G."</p>
</li>
<li><p><strong>Functional images</strong> (an icon inside a link) — describe the action. An envelope icon inside a link to <code>/contact</code> gets <code>alt="Contact us"</code>, not <code>alt="Envelope"</code>.</p>
</li>
<li><p><strong>Images of text</strong> — just don't. Use real text. If you must (a logo), the alt text is the text in the image.</p>
</li>
</ul>
<p>The Nigerian angle here is real: on flaky 3G, your images often don't load at all. Good alt text isn't just for screen readers — it's what appears in the broken-image box, which is what half your users in bad-signal areas will actually see. It's the only content your site has in that moment. Make it count.</p>
<p>For video and audio:</p>
<ul>
<li><p><strong>Video</strong> needs captions (for deaf users, and for everyone watching in public without headphones).</p>
</li>
<li><p><strong>Audio</strong> needs transcripts (for deaf users, and for everyone who wants to skim instead of listen).</p>
</li>
<li><p><strong>Autoplay</strong> with sound is evil. A user opening your page in a quiet office, or at night beside a sleeping partner, or in a lecture hall, will never forgive you. <code>muted</code> or nothing.</p>
</li>
</ul>
<hr />
<h2>Testing: What You Actually Have to Do</h2>
<p>Here's the unglamorous truth: automated tools catch about 30% of accessibility issues. The other 70% needs a human.</p>
<p>But that 30% is cheap and you should absolutely start there.</p>
<p><strong>Automated tools:</strong></p>
<ul>
<li><p><strong>axe DevTools</strong> — free browser extension. Run it on any page, get a list of violations with line numbers and fixes. Use this religiously.</p>
</li>
<li><p><strong>Lighthouse</strong> — Chrome DevTools → Lighthouse tab → Accessibility category. Gives you a score and a punch list.</p>
</li>
<li><p><strong>Pa11y</strong> — CLI tool for CI integration. Add it to your pipeline and fail the build if new accessibility errors show up.</p>
</li>
<li><p><strong>ESLint plugins</strong> — <code>eslint-plugin-jsx-a11y</code> catches a lot of issues at the code level, before they ever ship.</p>
</li>
</ul>
<p><strong>Manual tests you should do before every major release:</strong></p>
<ol>
<li><p><strong>Keyboard only.</strong> The test from earlier. Tab through the entire user flow. Can you complete it?</p>
</li>
<li><p><strong>Screen reader.</strong> On Mac, VoiceOver is built in — press Cmd+F5. On Windows, download NVDA, it's free and excellent. On Android, TalkBack is in accessibility settings. Just spend 10 minutes navigating your own app with it. The first time you do this, it will change how you build forever.</p>
</li>
<li><p><strong>200% zoom.</strong> Cmd+ a bunch of times in the browser. Does the layout hold? Does text get clipped?</p>
</li>
<li><p><strong>Sunlight test.</strong> Take your laptop outside, in bright sun, and try to use the site. I'm not kidding. This is the single best test for contrast, and it doesn't require any special tooling.</p>
</li>
<li><p><strong>One-handed test.</strong> Pick up your phone, put your other hand behind your back, and try to complete a key flow. Can you reach the primary CTA with your thumb? Or is it in a corner you need two hands to hit?</p>
</li>
</ol>
<p>Doing these five tests once a sprint catches more real bugs than every automated tool combined.</p>
<hr />
<h2>What I'd Do Differently</h2>
<p>Looking back on the projects where I've shipped accessibility debt, there's a clear pattern: accessibility was always treated as a "phase 2" item, something we'd "get to after launch." And then, like all phase 2 items, it never happened, or it happened badly, or it happened two years later when someone on the team got annoyed enough to retrofit it.</p>
<p>Retrofitting accessibility is an order of magnitude more expensive than building it in.</p>
<p>If I were starting a new project today, I'd do three things differently:</p>
<p><strong>1. Treat semantic HTML as a non-negotiable from day one.</strong> Every <code>&lt;div onClick&gt;</code> in PR review gets rejected. Not discussed. Not "we'll refactor later." Rejected. It costs the engineer 15 seconds to change. It costs the company weeks to retrofit later.</p>
<p><strong>2. Add an accessibility checklist to the PR template.</strong></p>
<pre><code class="language-markdown">## Accessibility
- [ ] Keyboard-navigable end-to-end
- [ ] Focus states visible on all interactive elements
- [ ] Form inputs have real labels
- [ ] Colour contrast passes WCAG AA
- [ ] axe DevTools run, no new violations
- [ ] Tested at 200% zoom
</code></pre>
<p>Six checkboxes. Maybe five minutes per PR. It builds the muscle.</p>
<p><strong>3. Do one real screen reader test per feature.</strong> Not a full audit, just ten minutes with VoiceOver before calling something done. The number of bugs this catches, silently, before they ship, is ridiculous.</p>
<p>One project I was on shipped a "revamped" dashboard. We had beautiful charts, great animations, a lovely dark mode. Three weeks after launch, a user wrote in to say the whole dashboard was unusable on a screen reader every chart was an unlabelled canvas, every filter was a styled div, the whole thing announced as "blank, blank, blank." Fixing it took three sprints. Building it right the first time would have taken maybe three extra days of planning and one day of extra implementation.</p>
<p>That's the real cost of treating accessibility as phase 2. Not the ethics, not the compliance — the pure engineering waste.</p>
<hr />
<h2>The Honest Conclusion</h2>
<p>Your user isn't sitting at a 27-inch monitor in a quiet office.</p>
<p>They're in a market in Ogbomoso, trying to pay before the next customer shoves them aside. They're in a club in Lekki, under strobing lights, trying to split a bill. They're on a Danfo bus at rush hour, holding a strap, trying to check if their transfer cleared. They're in a mall in Ikeja with a toddler on their hip, tapping through a checkout with one thumb. They're at home at 2AM, half-awake, trying to order food without waking their partner.</p>
<p>Some of them are blind. Some of them are colorblind. Some of them are temporarily one-handed because they just came from the hospital. Some of them just forgot their glasses at the office. Some of them are fine, they're just tired.</p>
<p>Every single one of them is using your app under conditions you never tested for. And accessibility isn't some charitable concession you make for "other people." It's the minimum, the floor, the thing that has to be there before anything else you build on top of it can actually reach the people you built it for.</p>
<p>You don't need to memorise every ARIA attribute. You don't need to become an a11y expert. You just need to care enough to do the five-minute keyboard test, to use the button tag instead of a div, to pick a colour that passes contrast, to write a real label, to run axe once before you ship.</p>
<p>Most users you lock out won't email you. They'll just leave. Quietly. Permanently.</p>
<p><strong>Accessible websites aren't harder to build. They're just built differently from the start.</strong></p>
<p>Build for the market, the mall, the club, the Danfo, the couch at 2AM. Build for everyone, because that's who's actually using the thing.</p>
<p>Bye.</p>
]]></content:encoded></item><item><title><![CDATA[JavaScript Temporal Dead Zone and Variable Declarations]]></title><description><![CDATA[Part 3 of the JavaScript Deep Dive Series
In our previous article, we learned that JavaScript has a compilation phase where it scans through your code and sets up variable declarations. But not all variable declarations behave the same way during thi...]]></description><link>https://crackedchefs.devferanmi.xyz/javascript-temporal-dead-zone-and-variable-declarations</link><guid isPermaLink="true">https://crackedchefs.devferanmi.xyz/javascript-temporal-dead-zone-and-variable-declarations</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[js]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Web Design]]></category><category><![CDATA[web developers]]></category><dc:creator><![CDATA[Oluwaferanmi Adeniji]]></dc:creator><pubDate>Thu, 06 Feb 2025 23:00:00 GMT</pubDate><content:encoded><![CDATA[<p><em>Part 3 of the</em> <strong><em>JavaScript Deep Dive Series</em></strong></p>
<p>In our previous article, we learned that JavaScript has a compilation phase where it scans through your code and sets up variable declarations. But <strong>not</strong> all variable declarations behave the same way during this process.</p>
<p>Understanding the differences between <code>var</code>, <code>let</code>, and <code>const</code> - and the "Temporal Dead Zone" is important for writing predictable JavaScript and avoiding common bugs.</p>
<h2 id="heading-definitions">Definitions</h2>
<p>Before we proceed, let's explain a few terms you will come accross:</p>
<p><strong>Temporal Dead Zone (TDZ)</strong> - The period between when a variable is hoisted (during compilation) and when it's initialized with a value. During this time, accessing the variable throws a ReferenceError.</p>
<p><strong>Block Scope</strong> - A scope created by curly braces <code>{}</code>. Variables declared with <code>let</code> and <code>const</code> are confined to the block where they're declared.</p>
<p><strong>Function Scope</strong> - A scope created by functions. Variables declared with <code>var</code> are confined to the function where they're declared (or global if outside any function).</p>
<p><strong>Hoisting</strong> - The process during compilation where variable and function declarations are processed before code execution begins.</p>
<p><strong>Initialization</strong> - The moment a variable gets its first value assignment.</p>
<h2 id="heading-the-three-variable-declarations">The Three Variable Declarations</h2>
<p>JavaScript gives you three ways to declare variables, each with different scoping and hoisting behavior:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">var</span> teacher = <span class="hljs-string">"Kyle"</span>;     <span class="hljs-comment">// Function-scoped, undefined when hoisted</span>
<span class="hljs-keyword">let</span> student = <span class="hljs-string">"Frank"</span>;    <span class="hljs-comment">// Block-scoped, TDZ when hoisted  </span>
<span class="hljs-keyword">const</span> course = <span class="hljs-string">"JS"</span>;      <span class="hljs-comment">// Block-scoped, TDZ when hoisted, immutable</span>
</code></pre>
<h3 id="heading-var">VAR</h3>
<p><code>var</code> declarations are <strong>function-scoped</strong> and get initialized with <code>undefined</code> during the compilation phase:</p>
<pre><code class="lang-javascript"><span class="hljs-built_in">console</span>.log(teacher);     <span class="hljs-comment">// undefined (not an error!)</span>
<span class="hljs-keyword">var</span> teacher = <span class="hljs-string">"Kyle"</span>;
<span class="hljs-built_in">console</span>.log(teacher);     <span class="hljs-comment">// "Kyle"</span>
</code></pre>
<p><strong>What happens:</strong></p>
<ol>
<li><p><strong>Compilation:</strong> <code>teacher</code> is declared in function scope, initialized with <code>undefined</code></p>
</li>
<li><p><strong>Execution:</strong> First <code>console.log</code> prints <code>undefined</code>, then assignment happens</p>
</li>
</ol>
<h3 id="heading-let-and-const-the-modern-approach">LET and CONST - The Modern Approach</h3>
<p><code>let</code> and <code>const</code> are <strong>block-scoped</strong> and exist in the Temporal Dead Zone until their declaration line:</p>
<pre><code class="lang-javascript"><span class="hljs-built_in">console</span>.log(student);     <span class="hljs-comment">// ReferenceError!</span>
<span class="hljs-keyword">let</span> student = <span class="hljs-string">"Frank"</span>;
<span class="hljs-built_in">console</span>.log(student);     <span class="hljs-comment">// "Frank"</span>
</code></pre>
<p><strong>What happens:</strong></p>
<ol>
<li><p><strong>Compilation:</strong> <code>student</code> is declared in block scope but NOT initialized</p>
</li>
<li><p><strong>Execution:</strong> First <code>console.log</code> tries to access uninitialized variable → ReferenceError</p>
</li>
</ol>
<h2 id="heading-block-scope-in-action">Block Scope in Action</h2>
<p>Block scope means variables are confined to the nearest set of curly braces:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">var</span> globalVar = <span class="hljs-string">"I'm global"</span>;

<span class="hljs-keyword">if</span> (<span class="hljs-literal">true</span>) {
    <span class="hljs-keyword">var</span> functionScoped = <span class="hljs-string">"I escape the block"</span>;
    <span class="hljs-keyword">let</span> blockScoped = <span class="hljs-string">"I'm trapped in the block"</span>;
    <span class="hljs-keyword">const</span> alsoBlocked = <span class="hljs-string">"Me too"</span>;
}

<span class="hljs-built_in">console</span>.log(functionScoped);  <span class="hljs-comment">// "I escape the block"</span>
<span class="hljs-built_in">console</span>.log(blockScoped);     <span class="hljs-comment">// ReferenceError!</span>
<span class="hljs-built_in">console</span>.log(alsoBlocked);     <span class="hljs-comment">// ReferenceError!</span>
</code></pre>
<p><strong>The difference:</strong></p>
<ul>
<li><p><code>var</code> ignores block boundaries (function-scoped)</p>
</li>
<li><p><code>let</code> and <code>const</code> respect block boundaries (block-scoped)</p>
</li>
</ul>
<h2 id="heading-temporal-dead-zone-tdz">Temporal Dead Zone (TDZ)</h2>
<p>The TDZ is the time between variable hoisting and initialization:</p>
<pre><code class="lang-javascript"><span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Before declaration"</span>);

<span class="hljs-built_in">console</span>.log(a);  <span class="hljs-comment">// ReferenceError - TDZ violation</span>
<span class="hljs-built_in">console</span>.log(b);  <span class="hljs-comment">// undefined - no TDZ for var</span>

<span class="hljs-keyword">let</span> a = <span class="hljs-string">"Hello"</span>;
<span class="hljs-keyword">var</span> b = <span class="hljs-string">"World"</span>;

<span class="hljs-built_in">console</span>.log(<span class="hljs-string">"After declaration"</span>);
<span class="hljs-built_in">console</span>.log(a);  <span class="hljs-comment">// "Hello"  </span>
<span class="hljs-built_in">console</span>.log(b);  <span class="hljs-comment">// "World"</span>
</code></pre>
<p><strong>TDZ Timeline:</strong></p>
<ol>
<li><p><strong>Compilation:</strong> Both <code>a</code> and <code>b</code> are hoisted</p>
</li>
<li><p><strong>Execution starts:</strong> <code>a</code> is in TDZ, <code>b</code> is <code>undefined</code></p>
</li>
<li><p><strong>Line 3:</strong> Accessing <code>a</code> → ReferenceError (TDZ violation)</p>
</li>
<li><p><strong>Line 4:</strong> Accessing <code>b</code> → <code>undefined</code> (allowed)</p>
</li>
<li><p><strong>Line 6:</strong> <code>a</code> exits TDZ, gets value "Hello"</p>
</li>
<li><p><strong>Line 9:</strong> Both variables accessible normally</p>
</li>
</ol>
<h2 id="heading-practical-implications">Practical Implications</h2>
<h3 id="heading-loop-behavior">Loop Behavior</h3>
<pre><code class="lang-javascript"><span class="hljs-comment">// var - function scoped, same variable</span>
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">var</span> i = <span class="hljs-number">0</span>; i &lt; <span class="hljs-number">3</span>; i++) {
    <span class="hljs-built_in">setTimeout</span>(<span class="hljs-function">() =&gt;</span> <span class="hljs-built_in">console</span>.log(i), <span class="hljs-number">100</span>);  <span class="hljs-comment">// 3, 3, 3</span>
}

<span class="hljs-comment">// let - block scoped, new variable each iteration  </span>
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i &lt; <span class="hljs-number">3</span>; i++) {
    <span class="hljs-built_in">setTimeout</span>(<span class="hljs-function">() =&gt;</span> <span class="hljs-built_in">console</span>.log(i), <span class="hljs-number">100</span>);  <span class="hljs-comment">// 0, 1, 2</span>
}
</code></pre>
<h3 id="heading-const-immutability">Const Immutability</h3>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> user = { <span class="hljs-attr">name</span>: <span class="hljs-string">"Kyle"</span> };
user.name = <span class="hljs-string">"Frank"</span>;        <span class="hljs-comment">// OK - modifying property</span>
<span class="hljs-built_in">console</span>.log(user.name);     <span class="hljs-comment">// "Frank"</span>

user = { <span class="hljs-attr">name</span>: <span class="hljs-string">"Suzy"</span> };    <span class="hljs-comment">// Error - reassigning const</span>
</code></pre>
<h3 id="heading-block-scope-benefits">Block Scope Benefits</h3>
<pre><code class="lang-javascript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">processData</span>(<span class="hljs-params"></span>) </span>{
    <span class="hljs-keyword">if</span> (condition) {
        <span class="hljs-keyword">let</span> tempData = calculateSomething();
        <span class="hljs-keyword">let</span> result = process(tempData);
        <span class="hljs-keyword">return</span> result;
        <span class="hljs-comment">// tempData and result die here</span>
    }
    <span class="hljs-comment">// tempData and result not accessible here</span>
}
</code></pre>
<h2 id="heading-common-misconceptions">Common Misconceptions</h2>
<p><strong>"let and const aren't hoisted"</strong> False. They are hoisted but remain uninitialized in the TDZ.</p>
<p><strong>"const means immutable"</strong><br />Partially false. The binding is immutable, but object/array contents can change.</p>
<p><strong>"var is always bad"</strong> False. <code>var</code> has legitimate use cases, especially for function-scoped variables.</p>
<p><strong>"Block scope is just for loops"</strong> False. Any <code>{}</code> creates block scope for <code>let</code>/<code>const</code>.</p>
<h2 id="heading-when-to-use-which">When to Use Which</h2>
<p><strong>Use</strong> <code>const</code> by default - Prevents accidental reassignment</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> API_URL = <span class="hljs-string">"https://api.example.com"</span>;
<span class="hljs-keyword">const</span> users = [];  <span class="hljs-comment">// Can still push to array</span>
</code></pre>
<p><strong>Use</strong> <code>let</code> when you need reassignment - Clear signal of mutability</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">let</span> counter = <span class="hljs-number">0</span>;
<span class="hljs-keyword">let</span> currentUser = <span class="hljs-literal">null</span>;
</code></pre>
<p><strong>Use</strong> <code>var</code> sparingly - Only when you specifically need function scope</p>
<pre><code class="lang-javascript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">processItems</span>(<span class="hljs-params"></span>) </span>{
    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">var</span> i = <span class="hljs-number">0</span>; i &lt; items.length; i++) {
        <span class="hljs-comment">// var i is function-scoped, accessible after loop</span>
    }
    <span class="hljs-keyword">return</span> i;  <span class="hljs-comment">// Final value of i</span>
}
</code></pre>
<h2 id="heading-summary-choose-your-declaration-wisely">Summary: Choose Your Declaration Wisely</h2>
<p>Understanding variable declarations is fundamental to JavaScript mastery:</p>
<p><strong>Compilation vs Execution:</strong> All declarations are hoisted, but initialized differently <strong>TDZ Protection:</strong> <code>let</code>/<code>const</code> prevent access before initialization<br /><strong>Scope Boundaries:</strong> <code>var</code> ignores blocks, <code>let</code>/<code>const</code> respect them <strong>Best Practices:</strong> Prefer <code>const</code>, use <code>let</code> for reassignment, avoid <code>var</code> unless necessary</p>
<p>This knowledge sets the foundation for understanding:</p>
<ul>
<li><p>Why certain bugs occur during development</p>
</li>
<li><p>How closures capture variables correctly</p>
</li>
<li><p>Module patterns and encapsulation strategies</p>
</li>
<li><p>Modern JavaScript best practices</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[JavaScript Lexical Scope and Compilation]]></title><description><![CDATA[Part 2 of the JavaScript Deep Dive Series
Before JavaScript executes a single line of your code, something crucial happens behind the scenes. While most developers think JavaScript executes and runs code line by line(I know I mentioned this in the fi...]]></description><link>https://crackedchefs.devferanmi.xyz/javascript-lexical-scope-and-compilation</link><guid isPermaLink="true">https://crackedchefs.devferanmi.xyz/javascript-lexical-scope-and-compilation</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[javascript framework]]></category><category><![CDATA[js]]></category><category><![CDATA[React]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Oluwaferanmi Adeniji]]></dc:creator><pubDate>Wed, 22 Jan 2025 23:00:00 GMT</pubDate><content:encoded><![CDATA[<p><em>Part 2 of the JavaScript Deep Dive Series</em></p>
<p>Before JavaScript executes a single line of your code, something crucial happens behind the scenes. While most developers think JavaScript executes and runs code line by line(I know I mentioned this in the first series), the reality is more sophisticated and understanding this process is the key to mastering scope, hoisting, and variable behavior. Javascript still executes code line by line, but before that it interprets/compiles the javascript code.<br />When you write a javascript code, the first thing that happens is what I refer to as “Parsing” also can be referred to as “Compilation” or interpretation.  </p>
<p>A relatable example is when you have a syntax error in your code on line 20, but immediately you recompile, you get an error, when infact your code hasn’t executed up to line 20, the reason why this error shows up immediately is because of the first step “Parsing/Compilation”, during this phase, Javascript engines runs through the code, define variables, verify syntaxes, etc.  </p>
<p>So, your code is first compiled, then executed, and that brings us to “The Two step process”.</p>
<h2 id="heading-javascripts-two-step-process">JavaScript's Two-Step Process</h2>
<p>Javascript follows a <strong>two-step process</strong>:</p>
<ol>
<li><p><strong>Compilation Phase</strong> - JavaScript scans through your entire code, setting up scope and preparing for execution</p>
</li>
<li><p><strong>Execution Phase</strong> - The actual running of your code, line by line</p>
</li>
</ol>
<h2 id="heading-this-compilation-step-is-where-all-the-magic-of-hoisting-scope-creation-and-variable-declarations-happens">This compilation step is where all the "magic" of <strong>hoisting</strong>, <strong>scope creation</strong>, and variable declarations happens.</h2>
<p><strong>Definitions</strong></p>
<p>Before we proceed into JavaScript's compilation process, let's explain a few terms you will come accross:</p>
<p><strong>Scope</strong> - The accessibility of variables and functions in different parts of your code. It determines where you can use a particular variable.</p>
<p><strong>Lexical Scope</strong> - Scope that is determined by where variables and functions are declared in your code (at write-time), not where they are called from (at runtime).</p>
<p><strong>Global Scope</strong> - The outermost scope in JavaScript. Variables declared here are accessible from anywhere in your program(more like in the window).</p>
<p><strong>Function Scope</strong> - A scope created inside a function. Variables declared here are only accessible within that function.</p>
<p><strong>Block Scope</strong> - A scope created by curly braces <code>{}</code> when used with <code>let</code> or <code>const</code>. Variables declared here are only accessible within that block.</p>
<p><strong>Source Reference</strong> - When a variable is being read or accessed (appears on the right-hand side of an assignment or in expressions like <code>console.log(variable)</code>).</p>
<p><strong>Target Reference</strong> - When a variable is being assigned a value (appears on the left-hand side of an assignment like <code>variable = "value"</code>).</p>
<p><strong>Compilation Phase</strong> - The first step where JavaScript scans through your code, sets up scope, and prepares variable declarations before any code executes.</p>
<p><strong>Execution Phase</strong> - The second step where JavaScript actually runs your code line by line.</p>
<p><strong>Scope Chain</strong> - The hierarchy of scopes that JavaScript searches through when looking up a variable, starting from the innermost scope and moving outward.</p>
<p><strong>Hoisting</strong> - The behavior where variable and function declarations are processed during the compilation phase, making them available throughout their scope even before their declaration line is reached during execution.</p>
<h3 id="heading-step-1-compilation-the-setup-phase">Step 1: Compilation - The Setup Phase</h3>
<p>During compilation, JavaScript's engine acts like an organizer, scanning through your code and asking two critical questions about every variable it encounters:</p>
<p><strong>"What scope does this belong to?"</strong><br /><strong>"Is this a source or target reference?"</strong></p>
<pre><code class="lang-javascript"><span class="hljs-keyword">var</span> teacher = <span class="hljs-string">"Kyle"</span>;

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">otherClass</span>(<span class="hljs-params"></span>) </span>{
    teacher = <span class="hljs-string">"Suzy"</span>;
    topic = <span class="hljs-string">"React"</span>;
    <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Welcome!"</span>);
}

otherClass();
</code></pre>
<p>Let's trace through the compilation phase:</p>
<p><strong>Line 1:</strong> <code>var teacher = "Kyle"</code></p>
<ul>
<li><p>Compiler finds <code>teacher</code> declaration</p>
</li>
<li><p>Creates <code>teacher</code> in <strong>global scope</strong></p>
</li>
<li><p>Notes: This will be a <strong>target reference</strong> (receiving a value)</p>
</li>
</ul>
<p><strong>Line 3:</strong> <code>function otherClass()</code></p>
<ul>
<li><p>Compiler creates <code>otherClass</code> function in <strong>global scope</strong></p>
</li>
<li><p>Creates <strong>new scope</strong> for inside the function</p>
</li>
</ul>
<p><strong>Line 4:</strong> <code>teacher = "Suzy"</code></p>
<ul>
<li><p>Compiler sees <code>teacher</code> reference</p>
</li>
<li><p>No declaration here, so it's a <strong>source reference</strong> (looking up existing variable)</p>
</li>
<li><p>Notes: Will look up scope chain during execution</p>
</li>
</ul>
<p><strong>Line 5:</strong> <code>topic = "React"</code></p>
<ul>
<li><p>Compiler sees <code>topic</code> reference</p>
</li>
<li><p>No declaration anywhere - this will create an <strong>implicit global</strong></p>
</li>
</ul>
<h2 id="heading-source-vs-target-references">Source vs Target References</h2>
<p>Understanding the difference between source and target references is crucial:</p>
<p><strong>Target Reference</strong> = <strong>Left-hand side</strong> of assignment (receiving a value)</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">var</span> student = <span class="hljs-string">"Frank"</span>;    <span class="hljs-comment">// student is TARGET</span>
teacher = <span class="hljs-string">"Kyle"</span>;         <span class="hljs-comment">// teacher is TARGET</span>
</code></pre>
<p><strong>Source Reference</strong> = <strong>Right-hand side</strong> of assignment (providing a value)</p>
<pre><code class="lang-javascript"><span class="hljs-built_in">console</span>.log(teacher);     <span class="hljs-comment">// teacher is SOURCE</span>
<span class="hljs-keyword">var</span> message = teacher;    <span class="hljs-comment">// teacher is SOURCE, message is TARGET</span>
</code></pre>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p>The engine handles source and target references completely differently:</p>
<p><strong>Target references:</strong> Must have a declared variable to assign to <strong>Source references:</strong> Must find an existing variable to read from</p>
<pre><code class="lang-javascript"><span class="hljs-comment">// Compilation phase creates these declarations</span>
<span class="hljs-keyword">var</span> teacher;              <span class="hljs-comment">// TARGET reference prepared</span>
<span class="hljs-keyword">var</span> student;              <span class="hljs-comment">// TARGET reference prepared</span>

<span class="hljs-comment">// Execution phase</span>
teacher = <span class="hljs-string">"Kyle"</span>;         <span class="hljs-comment">// TARGET - assigns to existing declaration</span>
<span class="hljs-built_in">console</span>.log(teacher);     <span class="hljs-comment">// SOURCE - looks up existing value</span>
topic = <span class="hljs-string">"React"</span>;          <span class="hljs-comment">// TARGET - no declaration found, creates global</span>
<span class="hljs-built_in">console</span>.log(subject);     <span class="hljs-comment">// SOURCE - no declaration found, ReferenceError!</span>
</code></pre>
<h2 id="heading-lexical-scope-where-you-write-matters">Lexical Scope - Where You Write Matters</h2>
<p><strong>Lexical scope</strong> means scope is determined by <strong>where you write your code</strong>, not where you call it. The physical placement of your variables and functions determines their scope relationships.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">var</span> teacher = <span class="hljs-string">"Kyle"</span>;

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">otherClass</span>(<span class="hljs-params"></span>) </span>{
    <span class="hljs-keyword">var</span> teacher = <span class="hljs-string">"Suzy"</span>;

    <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">ask</span>(<span class="hljs-params"></span>) </span>{
        <span class="hljs-built_in">console</span>.log(teacher);  <span class="hljs-comment">// Which teacher?</span>
    }

    ask();
}

otherClass(); <span class="hljs-comment">// "Suzy"</span>
</code></pre>
<p>The <code>ask()</code> function looks for <code>teacher</code> in this order:</p>
<ol>
<li><p><strong>Local scope</strong> (inside <code>ask</code>) - not found</p>
</li>
<li><p><strong>Enclosing scope</strong> (inside <code>otherClass</code>) - found "Suzy"!</p>
</li>
<li><p><strong>Global scope</strong> - never reaches here</p>
</li>
</ol>
<p>This lookup happens <strong>at compile time</strong> based on where you wrote the code, not where you called it.</p>
<h2 id="heading-scope-chain-resolution">Scope Chain Resolution</h2>
<p>JavaScript creates a <strong>scope chain</strong> during compilation - a linked list of scopes from innermost to outermost:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">var</span> <span class="hljs-built_in">global</span> = <span class="hljs-string">"I'm global"</span>;

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">outer</span>(<span class="hljs-params"></span>) </span>{
    <span class="hljs-keyword">var</span> outerVar = <span class="hljs-string">"I'm in outer"</span>;

    <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">inner</span>(<span class="hljs-params"></span>) </span>{
        <span class="hljs-keyword">var</span> innerVar = <span class="hljs-string">"I'm in inner"</span>;
        <span class="hljs-built_in">console</span>.log(innerVar);  <span class="hljs-comment">// Found in inner scope</span>
        <span class="hljs-built_in">console</span>.log(outerVar);  <span class="hljs-comment">// Found in outer scope  </span>
        <span class="hljs-built_in">console</span>.log(<span class="hljs-built_in">global</span>);    <span class="hljs-comment">// Found in global scope</span>
        <span class="hljs-built_in">console</span>.log(missing);   <span class="hljs-comment">// ReferenceError!</span>
    }

    inner();
}

outer();
</code></pre>
<p><strong>Scope chain for</strong> <code>inner()</code> function: Inner Scope → Outer Scope → Global Scope</p>
<p>The engine walks up this chain until it finds the variable or reaches the end (ReferenceError).</p>
<h2 id="heading-the-compilation-process-in-action">The Compilation Process in Action</h2>
<p>Let's trace through a complete example:</p>
<pre><code class="lang-javascript"><span class="hljs-built_in">console</span>.log(teacher);        <span class="hljs-comment">// What happens here?</span>

<span class="hljs-keyword">var</span> teacher = <span class="hljs-string">"Kyle"</span>;

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">ask</span>(<span class="hljs-params"></span>) </span>{
    <span class="hljs-built_in">console</span>.log(teacher);
}

ask();
</code></pre>
<p><strong>Compilation Phase:</strong></p>
<ol>
<li><p>Line 3: Create <code>teacher</code> declaration in global scope</p>
</li>
<li><p>Line 5: Create <code>ask</code> function in global scope</p>
</li>
<li><p>Line 6: Create new scope for <code>ask</code> function</p>
</li>
<li><p>All source references noted for execution phase</p>
</li>
</ol>
<p><strong>Execution Phase:</strong></p>
<ol>
<li><p>Line 1: <code>console.log(teacher)</code> - SOURCE reference to <code>teacher</code></p>
<ul>
<li><p><code>teacher</code> exists (from compilation) but value is <code>undefined</code></p>
</li>
<li><p>Output: <code>undefined</code></p>
</li>
</ul>
</li>
<li><p>Line 3: <code>teacher = "Kyle"</code> - TARGET reference</p>
<ul>
<li>Assigns "Kyle" to existing <code>teacher</code> declaration</li>
</ul>
</li>
<li><p>Line 8: <code>ask()</code> - Calls function</p>
</li>
<li><p>Line 6: <code>console.log(teacher)</code> - SOURCE reference</p>
<ul>
<li><p>Looks up <code>teacher</code> in scope chain, finds "Kyle"</p>
</li>
<li><p>Output: "Kyle"</p>
</li>
</ul>
</li>
</ol>
<h2 id="heading-common-scope-misconceptions">Common Scope Misconceptions</h2>
<p><strong>"Hoisting moves declarations to the top"</strong> False. Nothing physically moves. Compilation creates declarations before execution begins.</p>
<p><strong>"let and const aren't hoisted"</strong><br />False. They are hoisted but in a "temporal dead zone" until their line is reached.</p>
<p><strong>"Functions create scope"</strong> Partially true. Function declarations create scope, but so do blocks with <code>let</code>/<code>const</code>.</p>
<p><strong>"Scope is determined at runtime"</strong> False. Lexical scope is determined at compile time by where you write code.</p>
<h2 id="heading-practical-applications">Practical Applications</h2>
<p>Understanding compilation and lexical scope helps you:</p>
<p><strong>Debug Variable Access Issues</strong></p>
<pre><code class="lang-javascript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">broken</span>(<span class="hljs-params"></span>) </span>{
    <span class="hljs-built_in">console</span>.log(name);  <span class="hljs-comment">// ReferenceError - not undefined!</span>
    <span class="hljs-keyword">let</span> name = <span class="hljs-string">"Frank"</span>;
}
</code></pre>
<p><strong>Predict Hoisting Behavior</strong></p>
<pre><code class="lang-javascript"><span class="hljs-built_in">console</span>.log(<span class="hljs-keyword">typeof</span> teacher);  <span class="hljs-comment">// "undefined" (var hoisting)</span>
<span class="hljs-built_in">console</span>.log(<span class="hljs-keyword">typeof</span> student);  <span class="hljs-comment">// ReferenceError (let in TDZ)</span>

<span class="hljs-keyword">var</span> teacher = <span class="hljs-string">"Kyle"</span>;
<span class="hljs-keyword">let</span> student = <span class="hljs-string">"Frank"</span>;
</code></pre>
<p><strong>Understand Closure Creation</strong></p>
<pre><code class="lang-javascript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">makeCounter</span>(<span class="hljs-params"></span>) </span>{
    <span class="hljs-keyword">var</span> count = <span class="hljs-number">0</span>;
    <span class="hljs-keyword">return</span> <span class="hljs-function"><span class="hljs-keyword">function</span>(<span class="hljs-params"></span>) </span>{
        <span class="hljs-keyword">return</span> ++count;  <span class="hljs-comment">// Lexical scope preserves access to count</span>
    };
}
</code></pre>
]]></content:encoded></item><item><title><![CDATA[JavaScript's Thread of Execution - How Code Really Runs]]></title><description><![CDATA[Part 1 of the JavaScript Deep Dive Series
Welcome to JavaScript Deep Dive - a comprehensive series where we'll journey through JavaScript's inner workings, one concept at a time. Over the next several articles, we'll explore everything from basic exe...]]></description><link>https://crackedchefs.devferanmi.xyz/javascripts-thread-of-execution-how-code-really-runs</link><guid isPermaLink="true">https://crackedchefs.devferanmi.xyz/javascripts-thread-of-execution-how-code-really-runs</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Programming Blogs]]></category><category><![CDATA[javascript framework]]></category><category><![CDATA[Javascript library]]></category><dc:creator><![CDATA[Oluwaferanmi Adeniji]]></dc:creator><pubDate>Tue, 07 Jan 2025 23:00:00 GMT</pubDate><content:encoded><![CDATA[<p><em>Part 1 of the JavaScript Deep Dive Series</em></p>
<p><strong>Welcome to JavaScript Deep Dive</strong> - a comprehensive series where we'll journey through JavaScript's inner workings, one concept at a time. Over the next several articles, we'll explore everything from basic execution to advanced patterns like closures, promises, and object-oriented programming.</p>
<p><strong>What we'll cover in this series:</strong></p>
<ul>
<li><p><strong>Execution &amp; Memory</strong> - How JavaScript reads and stores your code</p>
</li>
<li><p><strong>Functions &amp; Execution Contexts</strong> - The foundation of JavaScript behavior</p>
</li>
<li><p><strong>Call Stack Fundamentals</strong> - Tracking function calls and nested execution</p>
</li>
<li><p><strong>Stack Overflow &amp; Limits</strong> - Understanding JavaScript's boundaries</p>
</li>
<li><p><strong>Closures &amp; Higher-Order Functions</strong> - The DRY principle in action</p>
</li>
<li><p><strong>Promises &amp; Async Patterns</strong> - Managing asynchronous operations</p>
</li>
<li><p><strong>Event Loop</strong> - How JavaScript handles non-blocking operations</p>
</li>
<li><p><strong>Object-Oriented Programming</strong> - The <code>new</code> keyword and beyond</p>
</li>
<li><p><strong>The</strong> <code>this</code> Keyword - Context and binding demystified</p>
</li>
<li><p><strong>Modern Classes</strong> - ES6+ OOP patterns</p>
</li>
</ul>
<h2 id="heading-javascripts-sequential-nature">JavaScript's Sequential Nature</h2>
<p>JavaScript operates on a simple principle: <strong>one thing happens at a time</strong>. Understanding this core concept is essential for writing predictable, debuggable code.</p>
<h3 id="heading-line-by-line-execution">Line-by-Line Execution</h3>
<p>JavaScript reads and executes code <strong>line by line, in order</strong>. When you write a program, JavaScript starts at the top and works its way down, executing each statement before moving to the next.</p>
<pre><code class="lang-javascript"><span class="hljs-built_in">console</span>.log(<span class="hljs-string">"First"</span>);    <span class="hljs-comment">// Executes first</span>
<span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Second"</span>);   <span class="hljs-comment">// Executes second  </span>
<span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Third"</span>);    <span class="hljs-comment">// Executes third</span>
<span class="hljs-comment">// Output: First, Second, Third</span>
</code></pre>
<p>This sequential execution means that the order you write code matters. Unlike human reading where you might skim or jump around, JavaScript is (and will be) methodical and predictable (in practice).</p>
<h3 id="heading-the-single-threaded-reality">The Single-Threaded Reality</h3>
<p>JavaScript is fundamentally <strong>single-threaded (read more about threading</strong> <a target="_blank" href="https://crackedchefs.devferanmi.xyz/introduction-to-cpu-threading-single-and-multi-threading?showSharer=true"><strong>here</strong></a><strong>),</strong> meaning it can only execute one command at a time. There's no parallel processing happening - each line must complete before the next begins.</p>
<p>This has profound implications:</p>
<ul>
<li><p><strong>Blocking operations</strong> stop everything else from running</p>
</li>
<li><p><strong>User interfaces</strong> can freeze if code takes too long</p>
</li>
<li><p><strong>Predictability</strong> is high - you know exactly what order things happen</p>
</li>
</ul>
<h2 id="heading-memory-storage-during-execution">Memory Storage During Execution</h2>
<p>As JavaScript executes code, it stores data in <strong>global memory</strong> (also called the variable environment). When you declare variables, JavaScript creates space for them and tracks their values.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> num = <span class="hljs-number">3</span>;        <span class="hljs-comment">// Creates 'num' in memory, stores 3</span>
<span class="hljs-keyword">const</span> string = <span class="hljs-string">"hello"</span>; <span class="hljs-comment">// Creates 'string' in memory, stores "hello"  </span>
<span class="hljs-keyword">let</span> result;           <span class="hljs-comment">// Creates 'result' in memory, initially undefined in value</span>
</code></pre>
<h3 id="heading-variable-hoisting">Variable Hoisting</h3>
<p>JavaScript actually scans through your code before executing it, setting up memory space for variables and functions. This is called <strong>hoisting</strong>.</p>
<p>javascript</p>
<pre><code class="lang-javascript"><span class="hljs-built_in">console</span>.log(myVar); <span class="hljs-comment">// undefined (not an error!)</span>
<span class="hljs-keyword">var</span> myVar = <span class="hljs-string">"Hello World"</span>;
<span class="hljs-built_in">console</span>.log(myVar); <span class="hljs-comment">// "Hello World"</span>
</code></pre>
<p>What actually happens:</p>
<ol>
<li><p>JavaScript creates <code>myVar</code> in memory during setup phase</p>
</li>
<li><p><code>myVar</code> initially gets the value <code>undefined</code></p>
</li>
<li><p>First <code>console.log</code> prints <code>undefined</code></p>
</li>
<li><p>Assignment happens: <code>myVar = "Hello World"</code></p>
</li>
<li><p>Second <code>console.log</code> prints "Hello World"</p>
</li>
</ol>
<h2 id="heading-tracing-execution-step-by-step">Tracing Execution Step by Step</h2>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> num = <span class="hljs-number">3</span>;
<span class="hljs-keyword">const</span> string = <span class="hljs-string">"hello"</span>;
<span class="hljs-keyword">let</span> result;

<span class="hljs-comment">// Trace through execution step by step</span>
<span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Step 1"</span>);
result = num + <span class="hljs-number">5</span>;
<span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Step 2"</span>);
<span class="hljs-built_in">console</span>.log(result);
</code></pre>
<p><strong>Execution order:</strong></p>
<ol>
<li><p><strong>Setup Phase</strong> (before any code runs):</p>
<ul>
<li><p>Create <code>num</code> in memory</p>
</li>
<li><p>Create <code>string</code> in memory</p>
</li>
<li><p>Create <code>result</code> in memory, value <code>undefined</code></p>
</li>
</ul>
</li>
<li><p><strong>Execution Phase</strong> (line by line):</p>
<ul>
<li><p>Line 1: Assign <code>3</code> to <code>num</code></p>
</li>
<li><p>Line 2: Assign <code>"hello"</code> to <code>string</code></p>
</li>
<li><p>Line 3: <code>result</code> stays <code>undefined</code> (no assignment yet)</p>
</li>
<li><p>Line 5: Execute <code>console.log("Step 1")</code> → outputs "Step 1"</p>
</li>
<li><p>Line 6: Calculate <code>num + 5</code> (3 + 5 = 8), assign to <code>result</code></p>
</li>
<li><p>Line 7: Execute <code>console.log("Step 2")</code> → outputs "Step 2"</p>
</li>
<li><p>Line 8: Execute <code>console.log(result)</code> → outputs <code>8</code></p>
</li>
</ul>
</li>
</ol>
<h2 id="heading-common-pitfalls-and-misconceptions">Common Pitfalls and Misconceptions</h2>
<h3 id="heading-javascript-runs-multiple-things-at-once">"JavaScript runs multiple things at once"</h3>
<p><strong>False.</strong> JavaScript is strictly single-threaded for code execution. Even asynchronous operations follow specific rules we'll cover in later articles.</p>
<h3 id="heading-variables-exist-before-theyre-declared">"Variables exist before they're declared"</h3>
<p><strong>Partially true</strong> due to hoisting, but they're <code>undefined</code> until assignment. Understanding the difference between declaration and assignment is crucial.</p>
<h3 id="heading-execution-order-doesnt-matter">"Execution order doesn't matter"</h3>
<p><strong>False.</strong> Order is critical for correct runtime behavior. This becomes even more important with functions and scope.</p>
<h3 id="heading-memory-management-is-completely-automatic">"Memory management is completely automatic"</h3>
<p><strong>Mostly true</strong>, but understanding when and how JavaScript manages memory helps you write more efficient code and avoid memory leaks.</p>
<h2 id="heading-practical-applications-better-debugging">Practical Applications: Better Debugging</h2>
<p>Understanding execution flow transforms how you debug:</p>
<h3 id="heading-reading-error-messages">Reading Error Messages</h3>
<p>When you see an error like "Cannot read property 'x' of undefined", you can trace back through execution to find exactly when and why a variable became undefined.</p>
<h3 id="heading-predicting-program-behavior">Predicting Program Behavior</h3>
<p>Before running code, you can mentally trace through the execution steps and predict the output. This skill is invaluable for both development and interviews.</p>
<h3 id="heading-identifying-performance-issues">Identifying Performance Issues</h3>
<p>When you understand that JavaScript can only do one thing at a time, you start thinking about which operations might block the user interface and how to structure code for better performance.</p>
<h2 id="heading-summary-your-mental-execution-model">Summary: Your Mental Execution Model</h2>
<p>The key takeaway is developing a mental model of JavaScript as a <strong>methodical, step-by-step executor</strong>:</p>
<ol>
<li><p><strong>Setup Phase</strong>: JavaScript scans code and sets up memory space</p>
</li>
<li><p><strong>Execution Phase</strong>: Code runs line by line, in order</p>
</li>
<li><p><strong>Memory Management</strong>: Variables are stored and tracked throughout</p>
</li>
<li><p><strong>Single-threaded</strong>: Only one operation at a time</p>
</li>
</ol>
<p>This foundation is crucial for understanding more complex concepts like:</p>
<ul>
<li><p>How functions create their own execution contexts</p>
</li>
<li><p>Why closures work the way they do</p>
</li>
<li><p>How asynchronous operations fit into single-threaded execution</p>
</li>
<li><p>Object creation and the <code>this</code> keyword</p>
</li>
</ul>
<p><strong>Next up:</strong> We'll explore how functions create their own execution contexts and why understanding this concept is the key to mastering JavaScript scope, closures, and advanced patterns.</p>
]]></content:encoded></item><item><title><![CDATA[Introduction to CPU Threading: Single and Multi-threading]]></title><description><![CDATA[Have you ever wondered why some programs feel blazingly fast while others make you wait? Why your phone sometimes freezes when you're doing "simple" tasks? Or why some apps can handle multiple things at once while others force you to wait?
The answer...]]></description><link>https://crackedchefs.devferanmi.xyz/introduction-to-cpu-threading-single-and-multi-threading</link><guid isPermaLink="true">https://crackedchefs.devferanmi.xyz/introduction-to-cpu-threading-single-and-multi-threading</guid><category><![CDATA[cpu]]></category><category><![CDATA[Programming Blogs]]></category><dc:creator><![CDATA[Oluwaferanmi Adeniji]]></dc:creator><pubDate>Tue, 31 Dec 2024 23:00:00 GMT</pubDate><content:encoded><![CDATA[<p>Have you ever wondered why some programs feel blazingly fast while others make you wait? Why your phone sometimes freezes when you're doing "simple" tasks? Or why some apps can handle multiple things at once while others force you to wait?</p>
<p>The answer lies in something called <strong>threading</strong> - and it's one of the most important concepts in computing that affects every single app you use.</p>
<h2 id="heading-what-is-threading-really">What is Threading, Really?</h2>
<p>Think of threading as <strong>how programs/process organize work</strong>. Just like a restaurant needs to decide whether to have one waiter handle all tables or multiple waiters working simultaneously, programs need to decide how to handle multiple tasks.</p>
<p><strong>Threading</strong> is essentially the program's strategy for getting things done. It determines whether your app can (<strong>oversimplified</strong>):</p>
<ul>
<li><p>Download a file while you browse other tabs</p>
</li>
<li><p>Play music while you text</p>
</li>
<li><p>Process your photos while you take new ones</p>
</li>
<li><p>Or forces you to wait for one thing to finish before starting another</p>
</li>
</ul>
<h2 id="heading-why-your-cpu-cares-about-threading">Why Your CPU Cares About Threading</h2>
<p>Your computer's processor (CPU) is like having multiple workers available, but whether your programs actually <em>use</em> those workers depends on how they're designed.</p>
<p>Modern phones and computers have <strong>multiple cores</strong> - think of each core as a separate worker. Your iPhone might have 6 cores, your laptop might have 8, and high-end computers can have 16 or more. H<strong>aving multiple workers doesn't automatically mean your program uses them all</strong>.</p>
<p>It's like having a construction site with 8 workers, but if the foreman (your program) only knows how to give instructions to one worker at a time, the other 7 just stand around waiting.</p>
<p>This is why some apps feel snappy and responsive while others feel sluggish, even on the same device/server.</p>
<h2 id="heading-why-should-you-care">Why Should You Care?</h2>
<p>Understanding threading helps you:</p>
<ul>
<li><p><strong>Choose better apps</strong> - Multi-threaded apps generally feel more responsive</p>
</li>
<li><p><strong>Understand performance</strong> - Why some tasks are fast vs slow</p>
</li>
<li><p><strong>Make better decisions</strong> - When to close apps, when to wait, when to restart</p>
</li>
<li><p><strong>Appreciate good software</strong> - Recognize when developers have done threading well</p>
</li>
</ul>
<p>Plus, if you're learning to code or working with developers, understanding threading helps you communicate about performance and user experience more effectively.</p>
<p>Think of your CPU as a <strong>restaurant kitchen</strong>, and each <strong>core as a chef (read more about</strong> <a target="_blank" href="https://www.tomshardware.com/news/cpu-core-definition,37658.html">cores here</a><strong>)</strong>.</p>
<h3 id="heading-single-threaded-one-chef-one-order-at-a-time">Single-Threaded: One Chef, One Order at a Time</h3>
<pre><code class="lang-javascript"><span class="hljs-comment">// JavaScript - Single-threaded</span>
<span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Taking order 1"</span>);      <span class="hljs-comment">// Chef takes order</span>
processPayment();                   <span class="hljs-comment">// Chef processes payment  </span>
cookMeal();                        <span class="hljs-comment">// Chef cooks meal</span>
serveMeal();                       <span class="hljs-comment">// Chef serves meal</span>
<span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Order 1 complete"</span>);   <span class="hljs-comment">// Only now can chef start order 2</span>
</code></pre>
<p><strong>What happens:</strong> One chef handles everything for one customer before moving to the next. If cooking takes 10 minutes, the entire restaurant waits.</p>
<p><strong>Pros:</strong> Simple, predictable, no coordination needed<br /><strong>Cons:</strong> Inefficient, blocking operations freeze everything</p>
<h3 id="heading-multi-threaded-multiple-chefs-parallel-work">Multi-Threaded: Multiple Chefs, Parallel Work</h3>
<pre><code class="lang-java"><span class="hljs-comment">// Java - Multi-threaded</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Restaurant</span> </span>{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">handleOrders</span><span class="hljs-params">()</span> </span>{
        <span class="hljs-comment">// Each thread (chef) works independently</span>
        Thread chef1 = <span class="hljs-keyword">new</span> Thread(() -&gt; processOrder(<span class="hljs-string">"Table 1"</span>));
        Thread chef2 = <span class="hljs-keyword">new</span> Thread(() -&gt; processOrder(<span class="hljs-string">"Table 2"</span>));
        Thread chef3 = <span class="hljs-keyword">new</span> Thread(() -&gt; processOrder(<span class="hljs-string">"Table 3"</span>));

        chef1.start(); <span class="hljs-comment">// All chefs work simultaneously</span>
        chef2.start();
        chef3.start();
    }
}
</code></pre>
<p><strong>What happens:</strong> Multiple chefs work on different orders simultaneously. While one chef cooks, another takes orders, and a third serves meals.</p>
<p><strong>Pros:</strong> Efficient, non-blocking, better resource utilization<br /><strong>Cons:</strong> Complex coordination, potential conflicts (two chefs grabbing the same ingredient)</p>
<h2 id="heading-how-this-maps-to-your-cpu">How This Maps to Your CPU</h2>
<p>Your <strong>CPU</strong> has multiple <strong>cores</strong> (chefs), and each core can handle <strong>threads</strong> (tasks):</p>
<ul>
<li><p><strong>Single-core CPU:</strong> One chef, must do everything sequentially</p>
</li>
<li><p><strong>Dual-core CPU:</strong> Two chefs, can handle two tasks simultaneously</p>
</li>
<li><p><strong>Quad-core CPU:</strong> Four chefs, even more parallel processing</p>
</li>
<li><p><strong>Modern CPUs:</strong> 8, 16, or more cores with hyper-threading (each chef can juggle 2 tasks)</p>
</li>
</ul>
<h2 id="heading-real-world-examples">Real-World Examples</h2>
<h3 id="heading-single-threaded-languages">Single-Threaded Languages</h3>
<p><strong>JavaScript, Python (GIL), PHP</strong></p>
<pre><code class="lang-javascript"><span class="hljs-comment">// Everything waits for this to finish</span>
<span class="hljs-keyword">for</span>(<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i &lt; <span class="hljs-number">1000000</span>; i++) {
    <span class="hljs-comment">// Heavy computation blocks everything</span>
}
<span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Finally done!"</span>); <span class="hljs-comment">// UI frozen until here</span>
</code></pre>
<h3 id="heading-multi-threaded-languages">Multi-Threaded Languages</h3>
<p><strong>Java, C#, Go, Rust</strong></p>
<pre><code class="lang-java"><span class="hljs-comment">// Heavy work happens in background</span>
CompletableFuture.runAsync(() -&gt; {
    <span class="hljs-comment">// Heavy computation on separate thread</span>
    heavyCalculation();
});

<span class="hljs-comment">// UI stays responsive</span>
System.out.println(<span class="hljs-string">"UI still works!"</span>);
</code></pre>
<h2 id="heading-the-trade-offs">The Trade-offs</h2>
<h3 id="heading-when-single-threading-works-well">When Single-Threading Works Well</h3>
<ul>
<li><p><strong>Simple applications</strong> where tasks are quick</p>
</li>
<li><p><strong>I/O heavy applications</strong> (like web servers using asynchronous patterns)</p>
</li>
<li><p><strong>When avoiding complexity</strong> is more important than performance</p>
</li>
</ul>
<h3 id="heading-when-multi-threading-shines">When Multi-Threading Shines</h3>
<ul>
<li><p><strong>CPU-intensive tasks</strong> (image processing, calculations)</p>
</li>
<li><p><strong>Applications serving many users</strong> simultaneously</p>
</li>
<li><p><strong>When you can break work</strong> into independent pieces</p>
</li>
</ul>
<h2 id="heading-quick-comparison">Quick Comparison</h2>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Aspect</td><td>Single-Threaded</td><td>Multi-Threaded</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Complexity</strong></td><td>Simple</td><td>Complex</td></tr>
<tr>
<td><strong>Performance</strong></td><td>Limited by one core</td><td>Can use all cores</td></tr>
<tr>
<td><strong>Debugging</strong></td><td>Predictable</td><td>Harder to debug</td></tr>
<tr>
<td><strong>Resource Usage</strong></td><td>Underutilizes CPU</td><td>Maximizes CPU usage</td></tr>
<tr>
<td><strong>Coordination</strong></td><td>Not needed</td><td>Critical</td></tr>
</tbody>
</table>
</div><p><strong>Bottom line is, Single-threading</strong> is like having one super-efficient chef who never makes mistakes but can only handle one thing at a time.</p>
<p><strong>Multi-threading</strong> is like having a full kitchen staff who can serve the whole restaurant simultaneously, but you need a head chef (programmer) who can coordinate them without chaos.</p>
<p>Choose based on your needs: simplicity and predictability vs. performance and resource utilization.</p>
]]></content:encoded></item><item><title><![CDATA[Writing Technical Implementation Documents: Complete Guide]]></title><description><![CDATA[Hey there, fellow Coding Chefs! 👋
Ever joined a project and spent three days just figuring out how the authentication system works? Or tried to add a simple feature only to discover it breaks two other components in mysterious ways? Or worse - found...]]></description><link>https://crackedchefs.devferanmi.xyz/writing-technical-implementation-documents-complete-guide</link><guid isPermaLink="true">https://crackedchefs.devferanmi.xyz/writing-technical-implementation-documents-complete-guide</guid><category><![CDATA[documentation]]></category><category><![CDATA[technical documentation]]></category><category><![CDATA[technical-deep-dive]]></category><category><![CDATA[Thinking]]></category><category><![CDATA[writing]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Frontend Development]]></category><category><![CDATA[backend]]></category><dc:creator><![CDATA[Oluwaferanmi Adeniji]]></dc:creator><pubDate>Wed, 02 Oct 2024 23:00:00 GMT</pubDate><content:encoded><![CDATA[<p>Hey there, fellow Coding Chefs! 👋</p>
<p>Ever joined a project and spent three days just figuring out how the authentication system works? Or tried to add a simple feature only to discover it breaks two other components in mysterious ways? Or worse - found yourself staring at your own code from six months ago, wondering what past-you was thinking?</p>
<p>The problem isn't your code. The problem is documentation.</p>
<p>Technical implementation documents are either non-existent, outdated, or written like academic papers that only make sense to the person who wrote them. But when done right, they're the difference between a smooth development process and weeks of confusion, debugging, and "why did we build it this way?" conversations.</p>
<p>Let's dive into writing technical implementation documents that actually help people build great software - including future you.</p>
<h2 id="heading-why-technical-docs-actually-matter-beyond-the-obvious">Why Technical Docs Actually Matter (Beyond the Obvious)</h2>
<p>Let's be real, most developers treat documentation like that gym membership they never use. "I'll definitely write docs... tomorrow... maybe next sprint... okay, fine, when the project is done."</p>
<p>But here's the thing: great technical implementation documents aren't just nice-to-have paperwork. They're the difference between a smooth development process and spending three weeks debugging why a feature that should have taken two days is mysteriously breaking everything else.</p>
<p>Think about it like this: when you're cooking, you don't just throw ingredients together and hope for the best (well, maybe you do, but your kitchen probably looks like a disaster zone). You plan the meal, understand how each ingredient interacts, and know what steps to take in what order.</p>
<p>Technical implementation documents are your recipe for building software that doesn't fall apart the moment someone else touches it.</p>
<h2 id="heading-the-anatomy-of-documents-that-actually-work">The Anatomy of Documents That Actually Work</h2>
<h3 id="heading-the-think-before-you-code-philosophy">The "Think Before You Code" Philosophy</h3>
<p>The best technical documents start before you write a single line of code. They're born from that crucial thinking phase where you're asking yourself the hard questions:</p>
<ul>
<li><p>What problem are we actually solving?</p>
</li>
<li><p>What are the different ways to solve this problem?</p>
</li>
<li><p>What could go wrong, and how do we handle it?</p>
</li>
<li><p>How does this fit into the bigger picture?</p>
</li>
</ul>
<p>I learned this the hard way when building a notification system that seemed simple on the surface. "Just send notifications when events happen," the requirements said. Easy, right?</p>
<p>Wrong.</p>
<p>Three weeks later, I was debugging race conditions, handling duplicate notifications, dealing with failed deliveries, and wondering why nobody mentioned that notifications needed to work across different time zones, support multiple languages, and gracefully handle users who'd disabled certain notification types.</p>
<p>A proper implementation document would have forced me to think through these scenarios upfront, instead of discovering them at 2 AM when the production system started melting down.</p>
<h3 id="heading-structure-that-makes-sense">Structure That Makes Sense</h3>
<p>Here's the thing about document structure - it should mirror how your brain actually processes information when approaching a new problem.</p>
<p><strong>Start with the "Why" (Context and Problem Statement)</strong></p>
<p>Don't jump straight into technical details. Start by explaining why this feature exists in the first place. What user problem does it solve? What business need does it address?</p>
<p>For example, instead of starting with "Implement user authentication using JWT tokens," start with "Users are frustrated with having to log in repeatedly across different parts of our application, leading to drop-offs and poor user experience."</p>
<p><strong>Then the "What" (Feature Overview)</strong></p>
<p>Now you can describe what you're building, but keep it high-level and user-focused. This section should be readable by non-technical stakeholders who need to understand the scope without getting lost in implementation details.</p>
<p><strong>The "How" (Implementation Strategy)</strong></p>
<p>This is where the technical meat lives, but don't just dump code everywhere. Explain your thinking process:</p>
<ul>
<li><p>Why did you choose this approach over alternatives?</p>
</li>
<li><p>What are the trade-offs you considered?</p>
</li>
<li><p>How does this integrate with existing systems?</p>
</li>
</ul>
<p><strong>The "What If" (Edge Cases and Error Handling)</strong></p>
<p>This is where most documents fail spectacularly. They focus on the happy path and completely ignore everything that can (and will) go wrong.</p>
<p>Real users don't follow your perfect user journey. They'll click buttons multiple times, have flaky internet connections, input weird data, and somehow find ways to break your system that you never imagined.</p>
<h3 id="heading-the-language-of-clarity">The Language of Clarity</h3>
<p>Technical writing doesn't mean you have to sound like a robot having a conversation with a database. Write like you're explaining the system to a smart colleague who's new to the project.</p>
<p>Instead of: "The authentication middleware intercepts incoming requests and validates JWT tokens against the configured secret key before allowing request propagation to downstream handlers."</p>
<p>Try: "When a user makes a request, our authentication system checks their login token to make sure they're allowed to access that feature. If the token is valid, the request continues normally. If not, we send them back to the login page."</p>
<p>Both say the same thing, but one actually helps people understand what's happening and why.</p>
<h2 id="heading-component-architecture-beyond-the-boxes-and-arrows">Component Architecture: Beyond the Boxes and Arrows</h2>
<h3 id="heading-the-lego-block-principle">The "Lego Block" Principle</h3>
<p>Good component architecture documentation doesn't just show what components exist - it explains how they fit together and why they're organized that way.</p>
<p>Think of your components like Lego blocks. Each block has a specific purpose, but the magic happens when you understand how they connect to build something bigger.</p>
<p>Instead of just listing components, explain the hierarchy and relationships:</p>
<pre><code class="lang-markdown">UserDashboard (main orchestrator)
├── ProfileSection (displays user info)
├── NotificationCenter (handles alerts)
│   ├── NotificationItem (individual alerts)
│   └── NotificationSettings (user preferences)
└── ActivityFeed (shows user actions)
<span class="hljs-code">    ├── ActivityItem (individual actions)
    └── ActivityFilter (sorting/filtering)</span>
</code></pre>
<p>But don't stop there. Explain why this structure makes sense:</p>
<p>"The UserDashboard acts as the main coordinator, fetching user data and passing it down to child components. Each section handles its own specific concerns - ProfileSection manages display logic, NotificationCenter handles alert workflows, and ActivityFeed deals with user action history. This separation means we can update notification logic without touching profile code, and vice versa."  </p>
<p>You can read more on seperating concerns and components architecture driven frontends <a target="_blank" href="https://crackedchefs.devferanmi.xyz/component-thinking-how-react-changed-my-entire-design-approach">here</a></p>
<h3 id="heading-state-management-strategy">State Management Strategy</h3>
<p>This is where things get interesting. State management is like organizing your kitchen - there are many ways to do it, but some approaches will drive you (and your teammates) absolutely crazy.</p>
<p>Document not just what state you're tracking, but where it lives and why:</p>
<p>"User authentication state lives in a global context because multiple components across the app need to know if someone is logged in. Notification preferences are stored locally in the NotificationCenter because only that component cares about them. Form data stays in individual form components unless it needs to persist across navigation, in which case it moves to session storage."</p>
<h3 id="heading-api-integration-patterns">API Integration Patterns</h3>
<p>API integration is where many projects turn into spaghetti code disasters. Your documentation should explain not just what endpoints you're calling, but how you're handling the inevitable chaos of network requests.</p>
<p>Instead of just listing API endpoints, explain the patterns:</p>
<p>"All API calls go through our apiClient utility, which handles authentication headers, retry logic, and error formatting automatically. For user-facing actions, we use optimistic updates - the UI changes immediately, then we sync with the server in the background. If the server request fails, we roll back the UI change and show an error message."</p>
<h2 id="heading-real-world-example-building-a-bank-statement-analyzer">Real-World Example: Building a Bank Statement Analyzer</h2>
<p>Let me walk you through how this looks in practice by documenting a bank statement analyzer app from scratch. By the end of this section, you'll have a complete technical implementation document you could actually hand to a development team.</p>
<p>Let's build this document together, step by step.</p>
<h3 id="heading-step-1-the-problem-context">Step 1: The Problem Context</h3>
<p>Every good technical document starts with understanding why the feature exists. Here's how we'd document our bank statement analyzer:</p>
<hr />
<p><strong>Bank Statement Analyzer - Problem Context</strong></p>
<p>"Many people struggle to understand their spending patterns and financial health. Traditional banking apps provide transaction lists but lack deeper insights into spending categories, trends, and financial behavior. Users want to upload their bank statements and receive comprehensive financial analysis without sharing sensitive data with third-party services permanently.</p>
<p>Current solutions either require expensive financial advisors, complex spreadsheet analysis, or sharing data with services that store information indefinitely. We need a solution that provides professional-level financial analysis while respecting user privacy through immediate data deletion after processing."</p>
<hr />
<p>See how we're not jumping into technical details yet? We're establishing the human problem first.</p>
<h3 id="heading-step-2-the-strategic-approach">Step 2: The Strategic Approach</h3>
<p>Now we explain what we're building and our high-level approach:</p>
<hr />
<p><strong>Solution Overview</strong></p>
<p>"We're building a client-side web application that allows users to upload CSV bank statements, processes them through AI analysis, and provides comprehensive financial insights. The key differentiator is our privacy-first approach - data is processed and immediately deleted, never stored permanently.</p>
<p>The application follows a linear workflow:</p>
<ol>
<li><p><strong>Upload Stage</strong>: Secure file upload with privacy consent</p>
</li>
<li><p><strong>Processing Stage</strong>: Real-time analysis progress with Claude AI</p>
</li>
<li><p><strong>Results Stage</strong>: Interactive financial analysis dashboard</p>
</li>
</ol>
<p>The architecture prioritizes user privacy, clear progress feedback, and comprehensive analysis presentation. Users can test the system with sample data before uploading personal information."</p>
<hr />
<h3 id="heading-step-3-component-architecture-amp-user-flow">Step 3: Component Architecture &amp; User Flow</h3>
<p>Here's where we break down the technical approach:</p>
<hr />
<p><strong>Application Architecture</strong></p>
<p>The application consists of four main stages, each handling specific user interactions and system processes:</p>
<pre><code class="lang-markdown">BankStatementAnalyzer (main application)
├── LandingPage (file upload interface)
│   ├── FileUploadDropzone (drag &amp; drop functionality)
│   ├── PrivacyConsentCheckbox (data processing agreement)
│   ├── CSVTemplateDownload (sample data provider)
│   └── FileValidation (CSV format verification)
├── ProcessingPage (analysis progress tracking)
│   ├── ProgressBar (visual progress indicator)
│   ├── ProcessingStatus (real-time status updates)
│   └── AnalysisPreview (partial results display)
├── ResultsPage (comprehensive financial analysis)
│   ├── SpendingCategorization (expense breakdown)
│   ├── TrendAnalysis (spending patterns over time)
│   ├── FinancialHealthScore (overall assessment)  
│   └── ActionableInsights (personalized recommendations)
└── ErrorBoundary (graceful error handling)
</code></pre>
<p><strong>Data Flow Architecture:</strong></p>
<pre><code class="lang-markdown">[User] → [File Upload] → [Client Validation] → [Claude API] → [Analysis] → [Results] → [Data Deletion]
</code></pre>
<p>The key architectural decision is processing everything client-side with API calls to Claude, ensuring no server-side data persistence.</p>
<hr />
<h3 id="heading-step-4-detailed-implementation-strategy">Step 4: Detailed Implementation Strategy</h3>
<p>Now we get into the technical meat:</p>
<hr />
<p><strong>Stage 1: Landing Page Implementation</strong></p>
<p>The landing page serves as the entry point and data collection interface. It must handle file validation, privacy consent, and provide testing capabilities.</p>
<p><strong>File Upload Strategy:</strong></p>
<ul>
<li><p>Support drag-and-drop and click-to-browse interfaces</p>
</li>
<li><p>Validate CSV format and file size (max 10MB) before upload</p>
</li>
<li><p>Provide real-time feedback on file validation status</p>
</li>
<li><p>Support multiple common CSV formats (comma, semicolon, tab-separated)</p>
</li>
</ul>
<p><strong>Privacy Consent Implementation:</strong></p>
<ul>
<li><p>Required checkbox: "I consent to my data being processed and deleted immediately after analysis"</p>
</li>
<li><p>Clear privacy policy explanation</p>
</li>
<li><p>Prevent upload until consent is given</p>
</li>
<li><p>Log consent status for audit purposes</p>
</li>
</ul>
<p><strong>CSV Template System:</strong></p>
<ul>
<li><p>Provide downloadable sample CSV with realistic (but fake) transaction data</p>
</li>
<li><p>Template includes: Date, Description, Amount, Category columns</p>
</li>
<li><p>Sample data demonstrates various transaction types and spending patterns</p>
</li>
<li><p>File naming: "sample_bank_statement.csv"</p>
</li>
</ul>
<p><strong>Edge Cases to Handle:</strong></p>
<ul>
<li><p>Invalid file formats (show specific error messages)</p>
</li>
<li><p>Files larger than size limit (progressive feedback)</p>
</li>
<li><p>Corrupted or empty CSV files</p>
</li>
<li><p>Users attempting upload without consent</p>
</li>
</ul>
<p><strong>Stage 2: Processing Page Implementation</strong></p>
<p>The processing page manages the analysis workflow and provides user feedback during potentially lengthy AI processing.</p>
<p><strong>Progress Tracking Strategy:</strong></p>
<ul>
<li><p>Break analysis into logical phases: "Parsing data", "Categorizing transactions", "Analyzing patterns", "Generating insights"</p>
</li>
<li><p>Show percentage complete for each phase</p>
</li>
<li><p>Estimated time remaining based on file size</p>
</li>
<li><p>Real-time status updates via WebSocket or polling</p>
</li>
</ul>
<p><strong>Claude API Integration:</strong></p>
<ul>
<li><p>Chunk large files into manageable segments for API processing</p>
</li>
<li><p>Implement retry logic with exponential backoff for failed requests</p>
</li>
<li><p>Handle rate limiting gracefully with user communication</p>
</li>
<li><p>Process data in stages: categorization → trend analysis → insights generation</p>
</li>
</ul>
<p><strong>User Experience Considerations:</strong></p>
<ul>
<li><p>Prevent page navigation/refresh during processing</p>
</li>
<li><p>Show preview insights as they become available</p>
</li>
<li><p>Provide cancel option with confirmation dialog</p>
</li>
<li><p>Handle browser tab switching (maintain processing state)</p>
</li>
</ul>
<p><strong>Stage 3: Results Page Implementation</strong></p>
<p>The results page presents comprehensive financial analysis in an digestible, interactive format.</p>
<p><strong>Analysis Categories:</strong></p>
<ul>
<li><p><strong>Spending Categorization</strong>: Food, Transportation, Entertainment, Bills, etc.</p>
</li>
<li><p><strong>Trend Analysis</strong>: Monthly spending patterns, seasonal variations</p>
</li>
<li><p><strong>Financial Health Metrics</strong>: Income-to-expense ratio, savings rate</p>
</li>
<li><p><strong>Behavioral Insights</strong>: Largest expenses, frequent merchants, unusual transactions</p>
</li>
</ul>
<p><strong>Data Visualization Strategy:</strong></p>
<ul>
<li><p>Interactive charts using Chart.js</p>
</li>
<li><p>Responsive design for mobile and desktop viewing</p>
</li>
<li><p>Export capabilities (PDF report, CSV summary)</p>
</li>
<li><p>Drill-down functionality for detailed transaction analysis</p>
</li>
</ul>
<p><strong>Security Implementation:</strong></p>
<ul>
<li><p>Automatic data deletion after results display (configurable timeout)</p>
</li>
<li><p>No local storage of sensitive transaction data</p>
</li>
<li><p>Clear indication when data has been purged</p>
</li>
<li><p>Option to download results before data deletion</p>
</li>
</ul>
<hr />
<h3 id="heading-step-5-error-handling-amp-edge-cases">Step 5: Error Handling &amp; Edge Cases</h3>
<p>This is where most documentation fails, but it's crucial for real-world applications:</p>
<hr />
<p><strong>Comprehensive Error Handling Strategy</strong></p>
<p><strong>File Upload Errors:</strong></p>
<ul>
<li><p>Invalid CSV format: "Please upload a valid CSV file with columns: Date, Description, Amount"</p>
</li>
<li><p>File size exceeded: "File size must be under 10MB. Consider splitting large statements."</p>
</li>
<li><p>Network failures: Retry mechanism with clear user communication</p>
</li>
<li><p>Unsupported file types: List supported formats and provide conversion guidance</p>
</li>
</ul>
<p><strong>Processing Errors:</strong></p>
<ul>
<li><p>Claude API failures: Fallback to basic categorization with user notification</p>
</li>
<li><p>Rate limiting: Queue system with estimated wait times</p>
</li>
<li><p>Network timeouts: Resume capability from last successful processing stage</p>
</li>
<li><p>Invalid data formats: Specific guidance on fixing common CSV issues</p>
</li>
</ul>
<p><strong>Privacy &amp; Security Errors:</strong></p>
<ul>
<li><p>Data deletion failures: Multiple deletion attempts with audit logging</p>
</li>
<li><p>Consent withdrawal: Immediate processing halt and data purge</p>
</li>
<li><p>Session timeout: Automatic data cleanup with user notification</p>
</li>
</ul>
<p><strong>Browser Compatibility Issues:</strong></p>
<ul>
<li><p>File API not supported: Graceful degradation with upload alternatives</p>
</li>
<li><p>JavaScript disabled: Server-side processing fallback (if implemented)</p>
</li>
<li><p>Mobile browser limitations: Responsive design with touch-friendly interfaces</p>
</li>
</ul>
<p><strong>Real-World Scenarios:</strong></p>
<ul>
<li><p>User uploads 2 years of transactions (large file handling)</p>
</li>
<li><p>Bank CSV has unusual formatting (flexible parser implementation)</p>
</li>
<li><p>User wants to analyze multiple accounts (batch processing capability)</p>
</li>
<li><p>Internet connection drops during processing (resume functionality)</p>
</li>
</ul>
<hr />
<h3 id="heading-step-6-technical-implementation-details">Step 6: Technical Implementation Details</h3>
<hr />
<p><strong>API Integration Specifications</strong></p>
<p><strong>Claude API Communication:</strong></p>
<pre><code class="lang-javascript"><span class="hljs-comment">// Pseudocode for Claude integration</span>
processTransactions(csvData) {
  <span class="hljs-comment">// Break data into chunks for processing</span>
  chunks = chunkTransactionData(csvData, <span class="hljs-number">1000</span>) <span class="hljs-comment">// 1000 transactions per chunk</span>

  results = []
  <span class="hljs-keyword">for</span> each chunk:
    analysis = <span class="hljs-keyword">await</span> claudeAPI.analyze({
      <span class="hljs-attr">data</span>: chunk,
      <span class="hljs-attr">analysisType</span>: [<span class="hljs-string">'categorization'</span>, <span class="hljs-string">'trends'</span>, <span class="hljs-string">'insights'</span>],
      <span class="hljs-attr">responseFormat</span>: <span class="hljs-string">'structured_json'</span>
    })
    results.push(analysis)
    updateProgressBar(chunk.index / chunks.length * <span class="hljs-number">100</span>)

  <span class="hljs-keyword">return</span> combineResults(results)
}
</code></pre>
<p><strong>Data Processing Pipeline:</strong></p>
<ol>
<li><p><strong>CSV Parsing</strong>: Convert uploaded file to structured data</p>
</li>
<li><p><strong>Data Cleaning</strong>: Remove duplicates, handle missing values</p>
</li>
<li><p><strong>Transaction Categorization</strong>: AI-powered expense categorization</p>
</li>
<li><p><strong>Trend Analysis</strong>: Time-series analysis of spending patterns</p>
</li>
<li><p><strong>Insight Generation</strong>: Personalized financial recommendations</p>
</li>
<li><p><strong>Result Formatting</strong>: Structure data for frontend visualization</p>
</li>
</ol>
<p><strong>Privacy Implementation:</strong></p>
<pre><code class="lang-javascript"><span class="hljs-comment">// Data lifecycle management</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">DataManager</span> </span>{
  <span class="hljs-keyword">constructor</span>() {
    <span class="hljs-built_in">this</span>.processingData = <span class="hljs-literal">null</span>
    <span class="hljs-built_in">this</span>.autoDeleteTimer = <span class="hljs-literal">null</span>
  }

  processFile(file) {
    <span class="hljs-built_in">this</span>.processingData = file
    <span class="hljs-built_in">this</span>.startAutoDeleteTimer(<span class="hljs-number">30</span> * <span class="hljs-number">60</span> * <span class="hljs-number">1000</span>) <span class="hljs-comment">// 30 minutes</span>
    <span class="hljs-comment">// Process data...</span>
  }

  deleteData() {
    <span class="hljs-built_in">this</span>.processingData = <span class="hljs-literal">null</span>
    clearMemoryReferences()
    logDataDeletion()
  }
}
</code></pre>
<hr />
<h3 id="heading-step-7-testing-amp-validation-strategy">Step 7: Testing &amp; Validation Strategy</h3>
<hr />
<p><strong>Testing Strategy</strong></p>
<p><strong>Unit Testing Focus:</strong></p>
<ul>
<li><p>CSV parser handles various bank formats correctly</p>
</li>
<li><p>Transaction categorization accuracy (test with known transaction types)</p>
</li>
<li><p>Progress tracking updates correctly during processing</p>
</li>
<li><p>Data deletion mechanisms work reliably</p>
</li>
</ul>
<p><strong>Integration Testing:</strong></p>
<ul>
<li><p>End-to-end upload → processing → results workflow</p>
</li>
<li><p>Claude API integration under various load conditions</p>
</li>
<li><p>Error recovery scenarios (network failures, API timeouts)</p>
</li>
<li><p>Cross-browser compatibility testing</p>
</li>
</ul>
<p><strong>User Acceptance Testing:</strong></p>
<ul>
<li><p>Upload sample CSV and verify analysis accuracy</p>
</li>
<li><p>Test privacy consent workflow</p>
</li>
<li><p>Validate results make sense for different spending patterns</p>
</li>
<li><p>Mobile device usability testing</p>
</li>
</ul>
<p><strong>Performance Testing:</strong></p>
<ul>
<li><p>Large file processing (&gt;5MB CSV files)</p>
</li>
<li><p>Multiple concurrent users</p>
</li>
<li><p>API response time optimization</p>
</li>
<li><p>Memory usage during processing</p>
</li>
</ul>
<hr />
<h3 id="heading-your-complete-technical-implementation-document">Your Complete Technical Implementation Document</h3>
<p>Congratulations! You've just walked through creating a comprehensive technical implementation document. Let's see what we built:</p>
<p><strong>What We Covered:</strong>  </p>
<p>✅ <strong>Problem Context</strong> - Why the feature exists<br />✅ <strong>Solution Overview</strong> - High-level approach and key decisions<br />✅ <strong>Component Architecture</strong> - How pieces fit together<br />✅ <strong>Implementation Strategy</strong> - Detailed technical approach for each stage<br />✅ <strong>Error Handling</strong> - Real-world edge cases and recovery strategies<br />✅ <strong>API Integration</strong> - Specific technical implementation details<br />✅ <strong>Testing Strategy</strong> - How to validate the implementation works</p>
<p><strong>What Makes This Document Effective:</strong></p>
<ul>
<li><p><strong>Starts with user problems</strong>, not technical solutions</p>
</li>
<li><p><strong>Explains architectural decisions</strong> and why alternatives were rejected</p>
</li>
<li><p><strong>Covers edge cases</strong> that will definitely happen in production</p>
</li>
<li><p><strong>Provides specific implementation guidance</strong> without being too prescriptive</p>
</li>
<li><p><strong>Includes testing strategy</strong> so teams know how to validate their work</p>
</li>
<li><p><strong>Considers privacy and security</strong> as first-class concerns</p>
</li>
</ul>
<p>This document could be handed to a development team today, and they'd have everything needed to build a production-ready bank statement analyzer. They'd understand not just what to build, but why it's built that way and how to handle the tricky parts.</p>
<h2 id="heading-common-pitfalls-and-how-to-avoid-them">Common Pitfalls (And How to Avoid Them)</h2>
<h3 id="heading-the-code-dump-trap">The "Code Dump" Trap</h3>
<p>The biggest mistake in technical documentation is treating it like a code dump. Just pasting code blocks with minimal explanation doesn't help anyone understand the system.</p>
<p>Bad example:</p>
<pre><code class="lang-javascript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">handleSubmit</span>(<span class="hljs-params">data</span>) </span>{
  <span class="hljs-keyword">if</span> (!data.partnerId) <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;
  submitSelection(data);
  updateState(<span class="hljs-string">'success'</span>);
  emitCallback({<span class="hljs-attr">selected</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">partner</span>: data});
}
</code></pre>
<p>Better example: "When users submit their partner selection, we first validate that they actually selected someone (users can be sneaky and try to submit empty forms). If validation passes, we send the selection to our API, update the widget to show success state, and notify the parent component about the selection so it can update its own interface accordingly."</p>
<h3 id="heading-the-everything-is-important-problem">The "Everything Is Important" Problem</h3>
<p>When everything is highlighted, nothing is highlighted. Not every implementation detail needs to be in your main documentation.</p>
<p>Focus on the decisions that matter:</p>
<ul>
<li><p>Why you chose this architecture over alternatives</p>
</li>
<li><p>How different components communicate</p>
</li>
<li><p>What happens when things go wrong</p>
</li>
<li><p>How the system will handle future changes</p>
</li>
</ul>
<p>Save the nitty-gritty implementation details for code comments and separate technical references.</p>
<h3 id="heading-the-its-obvious-assumption">The "It's Obvious" Assumption</h3>
<p>What's obvious to you after spending three weeks building something is definitely not obvious to the developer who's trying to understand it six months later (even if that developer is future you).</p>
<p>Document your assumptions and reasoning. If you decided to use a specific library, explain why. If you structured your state in a particular way, explain the thinking behind it.</p>
<h2 id="heading-testing-and-validation-strategies">Testing and Validation Strategies</h2>
<p>Great implementation documents don't just explain how to build something - they explain how to verify that it works correctly.</p>
<h3 id="heading-testing-philosophy">Testing Philosophy</h3>
<p>"Our testing strategy follows the principle of testing behavior, not implementation. We care that users can successfully select partners, not that our internal state management uses a specific pattern.</p>
<p>Component tests focus on user interactions: Can users search for partners? Does selection work correctly? Do error states display appropriate messages? Integration tests verify that the widget communicates correctly with parent components and handles API failures gracefully."</p>
<h3 id="heading-edge-case-validation">Edge Case Validation</h3>
<p>"We systematically test the scenarios that usually break in production:</p>
<ul>
<li><p>Network failures during search and submission</p>
</li>
<li><p>Rapid user interactions (clicking buttons multiple times)</p>
</li>
<li><p>Invalid or outdated data from the API</p>
</li>
<li><p>Keyboard navigation and screen reader accessibility</p>
</li>
<li><p>Mobile device interactions with touch interfaces"</p>
</li>
</ul>
<h2 id="heading-maintenance-and-evolution">Maintenance and Evolution</h2>
<p>The best technical documents acknowledge that software is a living thing that will change over time.</p>
<h3 id="heading-future-considerations">Future Considerations</h3>
<p>"This widget is designed to evolve. The current implementation handles single partner selection, but the architecture supports multiple selections with minimal changes. The search interface can be extended to support filters and sorting without affecting the core widget logic.</p>
<p>Key extension points:</p>
<ul>
<li><p>Additional search criteria can be added to the SearchInput component</p>
</li>
<li><p>New partner types can be supported by extending the PartnerCard component</p>
</li>
<li><p>Different selection workflows can be implemented as alternative modal components"</p>
</li>
</ul>
<h3 id="heading-migration-strategies">Migration Strategies</h3>
<p>"When rolling out this widget to replace existing partner selection implementations:</p>
<ol>
<li><p>Start with new features to validate the approach</p>
</li>
<li><p>Gradually migrate existing screens during their regular update cycles</p>
</li>
<li><p>Maintain backward compatibility with existing selection events</p>
</li>
<li><p>Provide clear migration guides for teams adopting the widget"</p>
</li>
</ol>
<h2 id="heading-team-collaboration-through-documentation">Team Collaboration Through Documentation</h2>
<h3 id="heading-bridging-the-communication-gap">Bridging the Communication Gap</h3>
<p>Technical implementation documents are often the bridge between different team members with different expertise levels. Your document might be read by:</p>
<ul>
<li><p>Frontend developers implementing the interface</p>
</li>
<li><p>Backend developers building supporting APIs</p>
</li>
<li><p>QA engineers creating test cases</p>
</li>
<li><p>Product managers understanding scope and limitations</p>
</li>
<li><p>Designers ensuring the implementation matches their vision</p>
</li>
</ul>
<p>Write with this diverse audience in mind. Use clear section headers so people can jump to what's relevant for them. Include diagrams and examples that help visual learners understand the concepts.</p>
<h3 id="heading-review-and-iteration">Review and Iteration</h3>
<p>The best documents are living documents that evolve with the project. Build review and update processes into your development workflow:</p>
<p>"This document will be updated as we learn from user feedback and encounter edge cases in production. Major architectural changes require document updates before implementation. All team members should feel empowered to suggest improvements to unclear sections."</p>
<h2 id="heading-tools-and-techniques">Tools and Techniques</h2>
<h3 id="heading-documentation-as-code">Documentation as Code</h3>
<p>Treat your technical documents like code. Use version control, peer reviews, and the same quality standards you apply to your implementation.</p>
<p>Keep documents close to the code they describe. If your widget lives in <code>/components/PartnerSelection/</code>, put the implementation document in <code>/docs/components/</code><a target="_blank" href="http://PartnerSelection.md"><code>PartnerSelection.md</code></a>. This makes it more likely that documents get updated when code changes.</p>
<h3 id="heading-diagrams-and-visual-aids">Diagrams and Visual Aids</h3>
<p>Sometimes a simple diagram communicates better than three paragraphs of text. Use tools like Mermaid, <a target="_blank" href="http://Draw.io">Draw.io</a>, or even hand-drawn sketches to illustrate:</p>
<ul>
<li><p>Component hierarchies</p>
</li>
<li><p>Data flow between components</p>
</li>
<li><p>User interaction workflows</p>
</li>
<li><p>State transitions</p>
</li>
<li><p>API communication patterns</p>
</li>
</ul>
<p>But don't go overboard. Diagrams should clarify, not complicate.</p>
<h3 id="heading-templates-and-consistency">Templates and Consistency</h3>
<p>Develop templates for common document types. Having a consistent structure makes it easier for team members to find information and reduces the cognitive load of starting new documents.</p>
<p>A basic implementation document template might include:</p>
<ul>
<li><p>Problem Context</p>
</li>
<li><p>Solution Overview</p>
</li>
<li><p>Architecture Decisions</p>
</li>
<li><p>Component Breakdown</p>
</li>
<li><p>API Integration</p>
</li>
<li><p>Error Handling</p>
</li>
<li><p>Testing Strategy</p>
</li>
<li><p>Future Considerations</p>
</li>
</ul>
<h2 id="heading-the-roi-of-good-documentation">The ROI of Good Documentation</h2>
<p>Let's talk about the elephant in the room - time. Writing good technical documentation takes time, and in a world of tight deadlines and feature requests, it often feels like a luxury.</p>
<p>But here's the math that changed my perspective:</p>
<p>A well-documented feature saves every future developer (including future you) about 2-4 hours of ramp-up time. If five people work on or reference that feature over its lifetime, you've saved 10-20 hours of collective time by spending 2-3 hours writing good documentation upfront.</p>
<p>More importantly, good documentation prevents the kinds of misunderstandings that lead to bugs, incorrect implementations, and frustrated team members. The cost of fixing these issues later is always higher than preventing them with clear communication upfront.</p>
<h2 id="heading-building-a-documentation-culture">Building a Documentation Culture</h2>
<h3 id="heading-making-it-part-of-the-process">Making It Part of the Process</h3>
<p>The best way to ensure documentation gets written is to make it a natural part of your development process, not an afterthought.</p>
<p>"Definition of Done" should include documentation updates. Code reviews should check that implementation matches documented design. Sprint planning should allocate time for documentation alongside development tasks.</p>
<h3 id="heading-leading-by-example">Leading by Example</h3>
<p>If you want your team to write better technical documents, start by writing better technical documents yourself. Share examples of documents that helped you understand complex systems. Celebrate when good documentation saves the team time or prevents bugs.</p>
<h3 id="heading-documentation-debugging">Documentation Debugging</h3>
<p>Treat unclear documentation like a bug. When someone asks questions that should be answered in your documentation, that's a sign the documentation needs improvement, not that the person asking is lazy.</p>
<h2 id="heading-wrapping-up-the-art-of-technical-storytelling">Wrapping Up: The Art of Technical Storytelling</h2>
<p>At its core, writing technical implementation documents is about storytelling. You're telling the story of how a system works, why it was built that way, and how someone else can successfully work with it.</p>
<p>The best technical documents don't just transfer information - they transfer understanding. They help readers build mental models of how systems work, so they can make good decisions when they need to modify or extend the code.</p>
<p>Remember: every system you build will eventually be maintained by someone else (including future you, who will have forgotten all the clever details). Write documentation that respects their time and intelligence. Explain not just what the code does, but why it does it that way.</p>
<p>Great technical documentation is a gift to your future self and your teammates. It's the difference between inheriting a well-organized kitchen with labeled ingredients and clear recipes, versus walking into a chaotic mess and hoping for the best.</p>
<p>Your code might work perfectly today, but your documentation determines whether it will still be maintainable and understandable six months from now.</p>
<p>A new day, another opportunity to build something well-documented and world-class! 🚀</p>
]]></content:encoded></item></channel></rss>