<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://code4projects.org/feed.xml" rel="self" type="application/atom+xml" /><link href="https://code4projects.org/" rel="alternate" type="text/html" /><updated>2026-10-16T17:52:47+00:00</updated><id>https://code4projects.org/feed.xml</id><title type="html">Code4Projects</title><subtitle>Code4Projects is a website about software programming where I write everything I learnt in over 30 years of experience.</subtitle><author><name>Salvatore D&apos;Angelo</name></author><entry><title type="html">Building the Complete Website: Real Pages, HTML Forms, and a Photo Gallery</title><link href="https://code4projects.org/building-complete-website-pages-forms-gallery/" rel="alternate" type="text/html" title="Building the Complete Website: Real Pages, HTML Forms, and a Photo Gallery" /><published>2026-10-09T00:00:00+00:00</published><updated>2026-10-09T00:00:00+00:00</updated><id>https://code4projects.org/building-complete-website-pages-forms-gallery</id><content type="html" xml:base="https://code4projects.org/building-complete-website-pages-forms-gallery/"><![CDATA[<p><img src="/assets/img/html-css-complete-pages.svg" alt="Building the Complete Website: Real Pages, HTML Forms, and a Photo Gallery" width="760" height="400" class="responsive_img" /></p>

<h2 id="introduction">Introduction</h2>

<p>In the <a href="/building-professional-website-layout-with-css/">previous article</a> you gave your site a real shell: a consistent header and footer, a navigation bar, social media icons, a styled home page, and a responsive hamburger menu. The five inner pages still had placeholder content — just a heading and a paragraph.</p>

<p>This is Part 4 of the series. If you missed the earlier articles, you can start from <a href="/getting-started-with-html/">Getting Started with HTML</a>, <a href="/mastering-basics-css/">Mastering the Basics of CSS</a>, or <a href="/building-professional-website-layout-with-css/">Building a Professional Website Layout with CSS</a>.</p>

<p>This article builds out those pages for real. Following the <a href="https://github.com/sasadangelo/html-hero/tree/master/part-3">Part 3 of the html-hero project</a> lesson by lesson, I will show you how to write the About Me, Start Here, and Resources pages, how to build a contact form with HTML form elements, and how to create an interactive photo gallery using CSS Grid and a handful of lines of JavaScript.</p>

<p>You should read this article if:</p>

<ul>
  <li>you completed Part 3 and want to move beyond layout into real content pages</li>
  <li>you have never built an HTML form and want to understand how <code class="language-plaintext highlighter-rouge">&lt;input&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;label&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;textarea&gt;</code>, and <code class="language-plaintext highlighter-rouge">&lt;button&gt;</code> work together</li>
  <li>you want to understand CSS Grid and how it differs from Flexbox for image-based layouts</li>
  <li>you want to add your first JavaScript to a web page without reaching for a framework</li>
</ul>

<p>All the source code is available in the <a href="https://github.com/sasadangelo/html-hero/tree/master/part-3">part-3 folder of the html-hero repository</a>. Each lesson has its own subfolder (<code class="language-plaintext highlighter-rouge">lesson-20</code> through <code class="language-plaintext highlighter-rouge">lesson-24</code>) so you can compare the result at every step.</p>

<h2 id="a-new-css-file-pagecss">A New CSS File: page.css</h2>

<p>Part 2 used two CSS files: <code class="language-plaintext highlighter-rouge">default.css</code> (shared by every page) and <code class="language-plaintext highlighter-rouge">home.css</code> (home page only). Part 3 introduces a third file, <code class="language-plaintext highlighter-rouge">page.css</code>, loaded by all inner pages.</p>

<p>The separation is deliberate. The home page has a hero image, feature rows, and a newsletter section — styles that make no sense on an article page. Inner pages share a different set of needs: a centred content container, floating images, styled blockquotes, form fields, and gallery cells.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"stylesheet"</span> <span class="na">type=</span><span class="s">"text/css"</span> <span class="na">href=</span><span class="s">"assets/css/default.css"</span><span class="nt">&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"stylesheet"</span> <span class="na">type=</span><span class="s">"text/css"</span> <span class="na">href=</span><span class="s">"assets/css/page.css"</span><span class="nt">&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"stylesheet"</span> <span class="na">type=</span><span class="s">"text/css"</span> <span class="na">href=</span><span class="s">"assets/css/brands.css"</span><span class="nt">&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"stylesheet"</span> <span class="na">type=</span><span class="s">"text/css"</span> <span class="na">href=</span><span class="s">"assets/css/fontawesome.css"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>The base rules in <code class="language-plaintext highlighter-rouge">page.css</code> centre the content area and handle text and image layout:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">#page-container</span> <span class="p">{</span>
    <span class="nl">max-width</span><span class="p">:</span> <span class="m">1080px</span><span class="p">;</span>
    <span class="nl">margin-left</span><span class="p">:</span> <span class="nb">auto</span><span class="p">;</span>
    <span class="nl">margin-right</span><span class="p">:</span> <span class="nb">auto</span><span class="p">;</span>
    <span class="nl">padding</span><span class="p">:</span> <span class="m">10px</span><span class="p">;</span>
    <span class="nl">overflow</span><span class="p">:</span> <span class="nb">hidden</span><span class="p">;</span>
<span class="p">}</span>

<span class="nt">p</span> <span class="p">{</span>
    <span class="nl">color</span><span class="p">:</span> <span class="nx">#666666</span><span class="p">;</span>
    <span class="nl">text-align</span><span class="p">:</span> <span class="nb">justify</span><span class="p">;</span>
<span class="p">}</span>

<span class="nt">img</span><span class="nc">.img-left</span> <span class="p">{</span>
    <span class="nl">float</span><span class="p">:</span> <span class="nb">left</span><span class="p">;</span>
    <span class="nl">padding</span><span class="p">:</span> <span class="m">10px</span> <span class="m">10px</span> <span class="m">10px</span> <span class="m">0px</span><span class="p">;</span>
<span class="p">}</span>

<span class="nt">img</span><span class="nc">.img-right</span> <span class="p">{</span>
    <span class="nl">float</span><span class="p">:</span> <span class="nb">right</span><span class="p">;</span>
    <span class="nl">padding</span><span class="p">:</span> <span class="m">10px</span> <span class="m">0px</span> <span class="m">10px</span> <span class="m">10px</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">overflow: hidden</code> on <code class="language-plaintext highlighter-rouge">#page-container</code> is a classic float-clearing trick: it forces the container to expand around any floated images inside it, preventing them from overflowing outside the box.</p>

<h2 id="the-about-me-page">The About Me Page</h2>

<p>The About Me page is the simplest of the five — it follows the same article-style structure as any inner page: a heading, paragraphs of text, and images floated left or right to break the visual monotony.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;main</span> <span class="na">class=</span><span class="s">"page-main"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"page-container"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;h1&gt;</span>About Me<span class="nt">&lt;/h1&gt;</span>
        <span class="nt">&lt;img</span> <span class="na">class=</span><span class="s">"img-left"</span> <span class="na">src=</span><span class="s">"assets/img/salvatore_d_angelo.jpeg"</span>
             <span class="na">alt=</span><span class="s">"Salvatore D'Angelo"</span> <span class="na">width=</span><span class="s">"150"</span> <span class="na">height=</span><span class="s">"150"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;p&gt;</span>
            My name is Salvatore D'Angelo and I am a professional software engineer ...
        <span class="nt">&lt;/p&gt;</span>
    <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/main&gt;</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">img-left</code> class floats the profile picture to the left so the text wraps around it on wider screens. On mobile (below 480 px) the float is removed and the image sits above the paragraph, which reads more naturally on a narrow screen:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* Mobile: no float, image sits above text */</span>
<span class="nt">img</span><span class="nc">.img-left</span> <span class="p">{</span>
    <span class="nl">padding</span><span class="p">:</span> <span class="m">10px</span><span class="p">;</span>
<span class="p">}</span>

<span class="c">/* Tablets and wider: float left */</span>
<span class="k">@media</span> <span class="nb">screen</span> <span class="n">and</span> <span class="p">(</span><span class="n">min-width</span><span class="p">:</span> <span class="m">480px</span><span class="p">)</span> <span class="p">{</span>
    <span class="nt">img</span><span class="nc">.img-left</span> <span class="p">{</span>
        <span class="nl">float</span><span class="p">:</span> <span class="nb">left</span><span class="p">;</span>
        <span class="nl">padding</span><span class="p">:</span> <span class="m">10px</span> <span class="m">10px</span> <span class="m">10px</span> <span class="m">0px</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<blockquote>
  <p>The <code class="language-plaintext highlighter-rouge">float</code> property was the original CSS layout mechanism before Flexbox and Grid existed. It is still the right tool when you want text to wrap around an image.</p>
</blockquote>

<h2 id="the-start-here-page-and-figure">The Start Here Page and <code class="language-plaintext highlighter-rouge">&lt;figure&gt;</code></h2>

<p>The Start Here page introduces two semantic HTML elements you have not seen yet: <code class="language-plaintext highlighter-rouge">&lt;figure&gt;</code> and <code class="language-plaintext highlighter-rouge">&lt;figcaption&gt;</code>.</p>

<p>A <code class="language-plaintext highlighter-rouge">&lt;figure&gt;</code> groups an image with its caption as a single semantic unit. Search engines and screen readers understand that the caption belongs to the image — something a plain <code class="language-plaintext highlighter-rouge">&lt;img&gt;</code> followed by a <code class="language-plaintext highlighter-rouge">&lt;p&gt;</code> does not communicate:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;figure&gt;</span>
    <span class="nt">&lt;img</span> <span class="na">class=</span><span class="s">"responsive_img"</span>
         <span class="na">src=</span><span class="s">"assets/img/software-developer2.jpg"</span>
         <span class="na">alt=</span><span class="s">"Software Developer"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;figcaption&gt;</span>
        Photo from
        <span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"https://clipartstation.com/"</span><span class="nt">&gt;</span>clipartstation.com<span class="nt">&lt;/a&gt;</span>
    <span class="nt">&lt;/figcaption&gt;</span>
<span class="nt">&lt;/figure&gt;</span>
</code></pre></div></div>

<p>The CSS in <code class="language-plaintext highlighter-rouge">page.css</code> handles the caption and link styling:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">figure</span> <span class="p">{</span>
    <span class="nl">margin</span><span class="p">:</span> <span class="m">0px</span><span class="p">;</span>
<span class="p">}</span>

<span class="nt">figure</span> <span class="nt">figcaption</span> <span class="nt">a</span> <span class="p">{</span>
    <span class="nl">color</span><span class="p">:</span> <span class="nx">#1D67B2</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">margin: 0</code> overrides the browser default margin on <code class="language-plaintext highlighter-rouge">&lt;figure&gt;</code> (some browsers add 40 px of horizontal margin by default), which would otherwise push the figure away from the page edges unexpectedly.</p>

<h2 id="the-resources-page">The Resources Page</h2>

<p>The Resources page is a curated list of links. It uses <code class="language-plaintext highlighter-rouge">&lt;ol&gt;</code> for ordered lists and demonstrates the <code class="language-plaintext highlighter-rouge">target="_blank"</code> attribute, which opens links in a new tab:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;a</span> <span class="na">href=</span><span class="s">"https://developer.mozilla.org/en-US/"</span>
   <span class="na">target=</span><span class="s">"_blank"</span> <span class="na">rel=</span><span class="s">"noopener noreferrer"</span><span class="nt">&gt;</span>
    MDN Web Docs
<span class="nt">&lt;/a&gt;</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">rel="noopener noreferrer"</code> attribute is not optional when using <code class="language-plaintext highlighter-rouge">target="_blank"</code>. Without it, the opened page can access the opener’s <code class="language-plaintext highlighter-rouge">window</code> object via <code class="language-plaintext highlighter-rouge">window.opener</code> — a security vulnerability known as reverse tabnapping. Always pair <code class="language-plaintext highlighter-rouge">target="_blank"</code> with <code class="language-plaintext highlighter-rouge">rel="noopener noreferrer"</code>.</p>

<p>The CSS rule <code class="language-plaintext highlighter-rouge">list-style-position: inside</code> keeps the list numbers visually aligned within the content container rather than hanging outside it:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">ol</span> <span class="p">{</span>
    <span class="nl">list-style-position</span><span class="p">:</span> <span class="nb">inside</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="the-contacts-page-and-html-forms">The Contacts Page and HTML Forms</h2>

<p>A contact form lets visitors send messages directly through the website. HTML provides all the elements needed — no JavaScript required for the structure.</p>

<p>The <code class="language-plaintext highlighter-rouge">&lt;form&gt;</code> tag is the container. Its two most important attributes are <code class="language-plaintext highlighter-rouge">action</code> (where to send the data) and <code class="language-plaintext highlighter-rouge">method</code> (how to send it). In this project <code class="language-plaintext highlighter-rouge">action</code> is empty because there is no backend server:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;form</span> <span class="na">onsubmit=</span><span class="s">"return false"</span><span class="nt">&gt;</span>

    <span class="nt">&lt;label</span> <span class="na">for=</span><span class="s">"name"</span><span class="nt">&gt;</span>Name:<span class="nt">&lt;/label&gt;</span>
    <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"text"</span> <span class="na">id=</span><span class="s">"name"</span> <span class="na">name=</span><span class="s">"name"</span>
           <span class="na">placeholder=</span><span class="s">"Your name"</span> <span class="na">required</span><span class="nt">&gt;</span>

    <span class="nt">&lt;label</span> <span class="na">for=</span><span class="s">"email"</span><span class="nt">&gt;</span>Email:<span class="nt">&lt;/label&gt;</span>
    <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"email"</span> <span class="na">id=</span><span class="s">"email"</span> <span class="na">name=</span><span class="s">"email"</span>
           <span class="na">placeholder=</span><span class="s">"Your email"</span> <span class="na">required</span><span class="nt">&gt;</span>

    <span class="nt">&lt;label</span> <span class="na">for=</span><span class="s">"message"</span><span class="nt">&gt;</span>Message:<span class="nt">&lt;/label&gt;</span>
    <span class="nt">&lt;textarea</span> <span class="na">id=</span><span class="s">"message"</span> <span class="na">name=</span><span class="s">"message"</span> <span class="na">rows=</span><span class="s">"8"</span>
              <span class="na">placeholder=</span><span class="s">"Write your message here..."</span><span class="nt">&gt;&lt;/textarea&gt;</span>

    <span class="nt">&lt;button</span> <span class="na">type=</span><span class="s">"submit"</span><span class="nt">&gt;</span>Send Message<span class="nt">&lt;/button&gt;</span>

<span class="nt">&lt;/form&gt;</span>
</code></pre></div></div>

<p>The key elements and their roles:</p>

<table>
  <thead>
    <tr>
      <th>Element</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;label&gt;</code></td>
      <td>Associates a visible description with a field; clicking the label focuses the field</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;input type="text"&gt;</code></td>
      <td>Single-line text field</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;input type="email"&gt;</code></td>
      <td>Email field — browser validates the format automatically</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;textarea&gt;</code></td>
      <td>Multi-line text field</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;button type="submit"&gt;</code></td>
      <td>Submits the form</td>
    </tr>
  </tbody>
</table>

<p>The <code class="language-plaintext highlighter-rouge">for</code> attribute on <code class="language-plaintext highlighter-rouge">&lt;label&gt;</code> must match the <code class="language-plaintext highlighter-rouge">id</code> attribute on the associated field. This pairing is essential for accessibility: screen readers announce the label when the field receives focus, and clicking the label moves focus to the field.</p>

<p>The <code class="language-plaintext highlighter-rouge">required</code> attribute activates browser-native validation — the form cannot be submitted if the field is empty, without a single line of JavaScript.</p>

<blockquote>
  <p><code class="language-plaintext highlighter-rouge">type="email"</code> is a free input validator: the browser rejects values that do not contain an <code class="language-plaintext highlighter-rouge">@</code> sign and a domain. It is not a substitute for server-side validation, but it catches obvious mistakes immediately.</p>
</blockquote>

<p>The form elements are styled in <code class="language-plaintext highlighter-rouge">page.css</code> to match the overall site look:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">form</span> <span class="nt">label</span> <span class="p">{</span>
    <span class="nl">display</span><span class="p">:</span> <span class="nb">block</span><span class="p">;</span>
    <span class="nl">margin-top</span><span class="p">:</span> <span class="m">10px</span><span class="p">;</span>
    <span class="nl">font-weight</span><span class="p">:</span> <span class="nb">bold</span><span class="p">;</span>
    <span class="nl">color</span><span class="p">:</span> <span class="nx">#444444</span><span class="p">;</span>
<span class="p">}</span>

<span class="nt">form</span> <span class="nt">input</span><span class="o">,</span>
<span class="nt">form</span> <span class="nt">textarea</span> <span class="p">{</span>
    <span class="nl">width</span><span class="p">:</span> <span class="m">100%</span><span class="p">;</span>
    <span class="nl">padding</span><span class="p">:</span> <span class="m">8px</span><span class="p">;</span>
    <span class="nl">border</span><span class="p">:</span> <span class="m">1px</span> <span class="nb">solid</span> <span class="nx">#cccccc</span><span class="p">;</span>
    <span class="nl">border-radius</span><span class="p">:</span> <span class="m">4px</span><span class="p">;</span>
    <span class="nl">box-sizing</span><span class="p">:</span> <span class="nb">border-box</span><span class="p">;</span>
<span class="p">}</span>

<span class="nt">form</span> <span class="nt">button</span> <span class="p">{</span>
    <span class="nl">margin-top</span><span class="p">:</span> <span class="m">12px</span><span class="p">;</span>
    <span class="nl">padding</span><span class="p">:</span> <span class="m">10px</span> <span class="m">24px</span><span class="p">;</span>
    <span class="nl">background-color</span><span class="p">:</span> <span class="nx">#1D67B2</span><span class="p">;</span>
    <span class="nl">color</span><span class="p">:</span> <span class="nx">#ffffff</span><span class="p">;</span>
    <span class="nl">border</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
    <span class="nl">border-radius</span><span class="p">:</span> <span class="m">4px</span><span class="p">;</span>
    <span class="nl">cursor</span><span class="p">:</span> <span class="nb">pointer</span><span class="p">;</span>
    <span class="nl">font-weight</span><span class="p">:</span> <span class="nb">bold</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">box-sizing: border-box</code> is important: without it, <code class="language-plaintext highlighter-rouge">width: 100%</code> plus <code class="language-plaintext highlighter-rouge">padding</code> would make the field wider than its container. With <code class="language-plaintext highlighter-rouge">border-box</code>, the padding is included in the declared width, so the field stays within bounds.</p>

<h2 id="the-gallery-page-css-grid-and-javascript">The Gallery Page: CSS Grid and JavaScript</h2>

<p>The gallery is the most technically interesting page in Part 3. It introduces two things: CSS Grid for the image grid layout, and a small JavaScript file for the lightbox viewer.</p>

<h3 id="css-grid-for-the-image-grid">CSS Grid for the Image Grid</h3>

<p>Flexbox is ideal for one-dimensional layouts — a row of icons, a column of cards. CSS Grid is designed for two dimensions: rows and columns simultaneously. A photo gallery is the perfect use case.</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.gallery-grid</span> <span class="p">{</span>
    <span class="nl">display</span><span class="p">:</span> <span class="nb">grid</span><span class="p">;</span>
    <span class="nl">grid-template-columns</span><span class="p">:</span> <span class="nf">repeat</span><span class="p">(</span><span class="nb">auto-fill</span><span class="p">,</span> <span class="nf">minmax</span><span class="p">(</span><span class="m">150px</span><span class="p">,</span> <span class="m">1fr</span><span class="p">));</span>
    <span class="nl">gap</span><span class="p">:</span> <span class="m">10px</span><span class="p">;</span>
    <span class="nl">margin-top</span><span class="p">:</span> <span class="m">20px</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">repeat(auto-fill, minmax(150px, 1fr))</code> value is the key. Breaking it down:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">auto-fill</code> — create as many columns as fit in the available width</li>
  <li><code class="language-plaintext highlighter-rouge">minmax(150px, 1fr)</code> — each column is at least 150 px wide and at most one equal fraction of the available space</li>
</ul>

<p>The result is a fully responsive grid: on a wide screen you get many columns; on a phone you might get two or three — with zero media queries.</p>

<p><img src="/assets/img/html-css-complete-pages-grid.svg" alt="CSS Grid — auto-fill responsive layout" width="760" height="340" class="responsive_img" /></p>

<p>Each cell clips the image to a fixed height:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.gallery-cell</span> <span class="nt">img</span> <span class="p">{</span>
    <span class="nl">width</span><span class="p">:</span> <span class="m">100%</span><span class="p">;</span>
    <span class="nl">height</span><span class="p">:</span> <span class="m">150px</span><span class="p">;</span>
    <span class="nl">object-fit</span><span class="p">:</span> <span class="nb">cover</span><span class="p">;</span>
    <span class="nl">cursor</span><span class="p">:</span> <span class="nb">pointer</span><span class="p">;</span>
    <span class="nl">opacity</span><span class="p">:</span> <span class="m">0.85</span><span class="p">;</span>
    <span class="nl">transition</span><span class="p">:</span> <span class="nf">opacity</span> <span class="m">0.2s</span> <span class="nb">ease</span><span class="p">;</span>
<span class="p">}</span>

<span class="nc">.gallery-cell</span> <span class="nt">img</span><span class="nd">:hover</span> <span class="p">{</span>
    <span class="nl">opacity</span><span class="p">:</span> <span class="m">1</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">object-fit: cover</code> scales the image to fill its cell without distortion, cropping the edges if necessary — the same behaviour as <code class="language-plaintext highlighter-rouge">background-size: cover</code> for background images.</p>

<h3 id="the-javascript-lightbox">The JavaScript Lightbox</h3>

<p>When a user clicks a thumbnail, a full-screen viewer opens. This requires JavaScript because it involves responding to a user event and changing the DOM.</p>

<p>The script is kept in a separate file, <code class="language-plaintext highlighter-rouge">assets/js/gallery.js</code>, and loaded with the <code class="language-plaintext highlighter-rouge">defer</code> attribute:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script </span><span class="na">type=</span><span class="s">"text/javascript"</span> <span class="na">src=</span><span class="s">"assets/js/gallery.js"</span> <span class="na">defer</span><span class="nt">&gt;&lt;/script&gt;</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">defer</code> attribute tells the browser to download the script in parallel with the HTML, but execute it only after the DOM is fully parsed. This prevents the classic error where a script tries to access an element that has not been created yet.</p>

<p>The gallery HTML uses an <code class="language-plaintext highlighter-rouge">onclick</code> attribute to call the JavaScript function:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"gallery-cell"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;img</span> <span class="na">src=</span><span class="s">"assets/gallery/photo1.jpg"</span>
         <span class="na">alt=</span><span class="s">"Photo 1"</span>
         <span class="na">onclick=</span><span class="s">"showImage(this)"</span><span class="nt">&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>The viewer is a hidden <code class="language-plaintext highlighter-rouge">&lt;div&gt;</code> that becomes visible when an image is clicked:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"gallery-viewer"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;span</span> <span class="na">id=</span><span class="s">"gallery-close"</span> <span class="na">onclick=</span><span class="s">"closeImage()"</span><span class="nt">&gt;</span><span class="ni">&amp;#215;</span><span class="nt">&lt;/span&gt;</span>
    <span class="nt">&lt;img</span> <span class="na">id=</span><span class="s">"gallery-expanded-img"</span> <span class="na">src=</span><span class="s">""</span> <span class="na">alt=</span><span class="s">""</span><span class="nt">&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>The JavaScript is minimal — two functions:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nf">showImage</span><span class="p">(</span><span class="nx">img</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">var</span> <span class="nx">viewer</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">"</span><span class="s2">gallery-viewer</span><span class="dl">"</span><span class="p">);</span>
    <span class="kd">var</span> <span class="nx">expandedImg</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">"</span><span class="s2">gallery-expanded-img</span><span class="dl">"</span><span class="p">);</span>
    <span class="nx">expandedImg</span><span class="p">.</span><span class="nx">src</span> <span class="o">=</span> <span class="nx">img</span><span class="p">.</span><span class="nx">src</span><span class="p">;</span>
    <span class="nx">expandedImg</span><span class="p">.</span><span class="nx">alt</span> <span class="o">=</span> <span class="nx">img</span><span class="p">.</span><span class="nx">alt</span><span class="p">;</span>
    <span class="nx">viewer</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">display</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">block</span><span class="dl">"</span><span class="p">;</span>
<span class="p">}</span>

<span class="kd">function</span> <span class="nf">closeImage</span><span class="p">()</span> <span class="p">{</span>
    <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">"</span><span class="s2">gallery-viewer</span><span class="dl">"</span><span class="p">).</span><span class="nx">style</span><span class="p">.</span><span class="nx">display</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">none</span><span class="dl">"</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">showImage</code> copies the <code class="language-plaintext highlighter-rouge">src</code> and <code class="language-plaintext highlighter-rouge">alt</code> from the clicked thumbnail into the expanded image, then makes the viewer visible by setting <code class="language-plaintext highlighter-rouge">style.display = "block"</code>. <code class="language-plaintext highlighter-rouge">closeImage</code> hides it again by setting <code class="language-plaintext highlighter-rouge">display = "none"</code>.</p>

<p>The viewer is styled as a fixed full-screen overlay:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">#gallery-viewer</span> <span class="p">{</span>
    <span class="nl">display</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
    <span class="nl">position</span><span class="p">:</span> <span class="nb">fixed</span><span class="p">;</span>
    <span class="nl">top</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
    <span class="nl">left</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
    <span class="nl">width</span><span class="p">:</span> <span class="m">100%</span><span class="p">;</span>
    <span class="nl">height</span><span class="p">:</span> <span class="m">100%</span><span class="p">;</span>
    <span class="nl">background-color</span><span class="p">:</span> <span class="nf">rgba</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0.9</span><span class="p">);</span>
    <span class="nl">z-index</span><span class="p">:</span> <span class="m">100</span><span class="p">;</span>
    <span class="nl">text-align</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
    <span class="nl">padding-top</span><span class="p">:</span> <span class="m">50px</span><span class="p">;</span>
    <span class="nl">box-sizing</span><span class="p">:</span> <span class="nb">border-box</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">position: fixed</code> positions the viewer relative to the viewport, not the page — it stays in place even when the user scrolls. <code class="language-plaintext highlighter-rouge">z-index: 100</code> places it above everything else on the page.</p>

<h2 id="the-final-file-structure">The Final File Structure</h2>

<p>After all Part 3 lessons, the project has grown from a layout shell to a complete multi-page site:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>lesson-24/
├── index.html
├── about-me.html
├── start-here.html
├── resources.html
├── contacts.html
├── gallery.html
└── assets/
    ├── css/
    │   ├── default.css       ← shared by all pages
    │   ├── home.css          ← home page only
    │   ├── page.css          ← inner pages, form, gallery
    │   ├── fontawesome.css   ← Font Awesome core
    │   └── brands.css        ← Font Awesome brand icons
    ├── fontawesome/
    │   └── webfonts/         ← .ttf and .woff2 font files
    ├── img/                  ← site images
    ├── gallery/              ← gallery photos
    └── js/
        └── gallery.js        ← gallery lightbox
</code></pre></div></div>

<p>The three-CSS-file architecture (<code class="language-plaintext highlighter-rouge">default.css</code>, <code class="language-plaintext highlighter-rouge">home.css</code>, <code class="language-plaintext highlighter-rouge">page.css</code>) keeps each stylesheet focused: shared structure, home-page layout, and inner-page content styles never mix. Adding a new page type in the future means adding a new CSS file, not modifying an existing one.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this article we covered:</p>

<ul>
  <li>How to introduce <code class="language-plaintext highlighter-rouge">page.css</code> to separate inner-page styles from home-page styles</li>
  <li>How to use floated images on article pages and remove the float on mobile with media queries</li>
  <li>How <code class="language-plaintext highlighter-rouge">&lt;figure&gt;</code> and <code class="language-plaintext highlighter-rouge">&lt;figcaption&gt;</code> communicate image-caption relationships to browsers and assistive technologies</li>
  <li>How <code class="language-plaintext highlighter-rouge">target="_blank"</code> combined with <code class="language-plaintext highlighter-rouge">rel="noopener noreferrer"</code> safely opens links in new tabs</li>
  <li>How to build a contact form with <code class="language-plaintext highlighter-rouge">&lt;input&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;label&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;textarea&gt;</code>, and <code class="language-plaintext highlighter-rouge">&lt;button&gt;</code>, and how browser-native validation works</li>
  <li>How CSS Grid with <code class="language-plaintext highlighter-rouge">repeat(auto-fill, minmax(...))</code> creates a fully responsive photo grid without media queries</li>
  <li>How to write a JavaScript lightbox in two functions and load it with <code class="language-plaintext highlighter-rouge">defer</code></li>
</ul>

<p>The series has now covered the full beginner path: from a blank HTML file to a complete, responsive, multi-page website with a form and an interactive gallery. This is the final article in the series.</p>

<hr />

<p>If you enjoyed this article, don’t forget to <strong>give it a clap 👏</strong>, <strong>share it with your friends 🔗</strong>, and <strong>follow me for more tips and tutorials on software development 📘</strong>. Your support helps me create more content like this — thank you! 🙌</p>

<p><strong>Note</strong>: English is not my native language, and this article was drafted with the assistance of AI. However, all the ideas, projects, concepts, and content are entirely my own.</p>]]></content><author><name>Salvatore D&apos;Angelo</name></author><category term="Programming" /><category term="programming" /><category term="html" /><category term="css" /><summary type="html"><![CDATA[Build the real inner pages of your site — About Me, Start Here, Resources, a contact form, and an interactive photo gallery with CSS Grid and JavaScript.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://code4projects.org/assets/img/html-css-complete-pages.svg" /><media:content medium="image" url="https://code4projects.org/assets/img/html-css-complete-pages.svg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Building a Professional Website Layout with CSS: Header, Navigation, and Responsive Design</title><link href="https://code4projects.org/building-professional-website-layout-with-css/" rel="alternate" type="text/html" title="Building a Professional Website Layout with CSS: Header, Navigation, and Responsive Design" /><published>2026-10-07T00:00:00+00:00</published><updated>2026-10-07T00:00:00+00:00</updated><id>https://code4projects.org/building-professional-website-layout-with-css</id><content type="html" xml:base="https://code4projects.org/building-professional-website-layout-with-css/"><![CDATA[<p><img src="/assets/img/html-css-layout.svg" alt="Building a Professional Website Layout with CSS" width="760" height="400" class="responsive_img" /></p>

<h2 id="introduction">Introduction</h2>

<p>In the <a href="/getting-started-with-html/">previous articles</a> of this series you built a five-page HTML website from scratch, and in <a href="/mastering-basics-css/">Mastering the Basics of CSS</a> you learned the theory behind stylesheets. You have working pages, but they still look like a plain document from 1999: no header, no navigation bar, no branding, and nothing that adapts to a phone screen.</p>

<p>This article changes that. Following the <a href="https://github.com/sasadangelo/html-hero/tree/master/part-2">Part 2 of the html-hero project</a> step by step, I will show you how to turn those raw HTML pages into a visually consistent site with a real layout, social icons, a styled home page, and a responsive mobile menu.</p>

<p>You should read this article if:</p>

<ul>
  <li>you completed the first HTML article and want to continue building the project</li>
  <li>you know basic CSS properties but have never applied them to a real multi-page layout</li>
  <li>you want to understand how header, footer, navigation bar, and responsive design actually work in practice — not just in theory</li>
</ul>

<p>All the source code is available in the <a href="https://github.com/sasadangelo/html-hero/tree/master/part-2">part-2 folder of the html-hero repository</a>. Each lesson has its own subfolder (<code class="language-plaintext highlighter-rouge">lesson-13</code> through <code class="language-plaintext highlighter-rouge">lesson-19</code>) so you can compare the result at every step.</p>

<h2 id="your-first-css-file">Your First CSS File</h2>

<p>Before building the layout, let’s quickly cover what Lesson 11 introduced at the end of Part 1: the first external CSS file.</p>

<p>Up to Lesson 10, all pages were unstyled HTML. Lesson 11 adds a <code class="language-plaintext highlighter-rouge">default.css</code> file linked from every page with a single <code class="language-plaintext highlighter-rouge">&lt;link&gt;</code> tag in the <code class="language-plaintext highlighter-rouge">&lt;head&gt;</code>:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;head&gt;</span>
    <span class="nt">&lt;meta</span> <span class="na">charset=</span><span class="s">"UTF-8"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;title&gt;</span>HTML Hero<span class="nt">&lt;/title&gt;</span>
    <span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"stylesheet"</span> <span class="na">type=</span><span class="s">"text/css"</span> <span class="na">href=</span><span class="s">"assets/css/default.css"</span><span class="nt">&gt;</span>
<span class="nt">&lt;/head&gt;</span>
</code></pre></div></div>

<p>This one stylesheet applies to all pages, which is exactly the right approach for a consistent site. Its base rules set the font, constrain the content width, and add basic typography:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">body</span> <span class="p">{</span>
    <span class="nl">margin</span><span class="p">:</span> <span class="m">0</span> <span class="nb">auto</span><span class="p">;</span>
    <span class="nl">font-family</span><span class="p">:</span> <span class="s2">'Helvetica Neue'</span><span class="p">;</span>
    <span class="nl">font-size</span><span class="p">:</span> <span class="m">13px</span><span class="p">;</span>
<span class="p">}</span>

<span class="nt">main</span> <span class="p">{</span>
    <span class="nl">max-width</span><span class="p">:</span> <span class="m">1080px</span><span class="p">;</span>
    <span class="nl">margin-left</span><span class="p">:</span> <span class="nb">auto</span><span class="p">;</span>
    <span class="nl">margin-right</span><span class="p">:</span> <span class="nb">auto</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">max-width</code> on <code class="language-plaintext highlighter-rouge">main</code> keeps the content readable on wide monitors while the <code class="language-plaintext highlighter-rouge">auto</code> margins keep it centred. This pattern is used throughout the entire project.</p>

<h2 id="publishing-your-website">Publishing Your Website</h2>

<p>Lesson 12 is about going live. The project uses <a href="https://it.altervista.org/">Altervista</a>, a free Italian hosting service, and <a href="https://filezilla-project.org/">FileZilla</a> as the FTP client to upload files.</p>

<p>The steps are straightforward: create a free space on Altervista, switch it from the default WordPress plan to a plain hosting plan, then connect FileZilla with the FTP credentials Altervista provides, and upload all your files. This is optional — you can also follow along locally — but it is a useful exercise for understanding how websites actually get published.</p>

<h2 id="header-and-footer-with-semantic-html">Header and Footer with Semantic HTML</h2>

<p>Part 2 starts properly here. The first thing every real website needs is a consistent header and footer on every page.</p>

<p>HTML5 introduced the <code class="language-plaintext highlighter-rouge">&lt;header&gt;</code> and <code class="language-plaintext highlighter-rouge">&lt;footer&gt;</code> tags specifically for these regions. Using them instead of a generic <code class="language-plaintext highlighter-rouge">&lt;div&gt;</code> has two benefits: the code is more readable, and search engines and screen readers understand the structure better.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;body&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"page"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;header&gt;</span>
            <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"header-group"</span><span class="nt">&gt;</span>
                <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"logo"</span><span class="nt">&gt;</span>
                    <span class="nt">&lt;img</span> <span class="na">src=</span><span class="s">"assets/img/code4projects-logo-mini.webp"</span>
                         <span class="na">alt=</span><span class="s">"Code4Projects Logo"</span> <span class="na">width=</span><span class="s">"150"</span> <span class="na">height=</span><span class="s">"150"</span><span class="nt">&gt;</span>
                <span class="nt">&lt;/div&gt;</span>
            <span class="nt">&lt;/div&gt;</span>
        <span class="nt">&lt;/header&gt;</span>
        <span class="nt">&lt;main&gt;</span>
            <span class="c">&lt;!-- page content here --&gt;</span>
        <span class="nt">&lt;/main&gt;</span>
        <span class="nt">&lt;footer&gt;</span>
            <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"site-info"</span><span class="nt">&gt;</span>
                Code4Projects, Copyright Salvatore D'Angelo <span class="ni">&amp;copy;</span> 2016-2025.
            <span class="nt">&lt;/div&gt;</span>
        <span class="nt">&lt;/footer&gt;</span>
    <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/body&gt;</span>
</code></pre></div></div>

<p>The CSS gives the header a light background and fixes its height. The <code class="language-plaintext highlighter-rouge">#header-group</code> container uses Flexbox to position the logo and, in later lessons, the social icons side by side:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">header</span> <span class="p">{</span>
    <span class="nl">background-color</span><span class="p">:</span> <span class="nx">#FCFCFC</span><span class="p">;</span>
    <span class="nl">width</span><span class="p">:</span> <span class="m">100%</span><span class="p">;</span>
<span class="p">}</span>

<span class="nf">#header-group</span> <span class="p">{</span>
    <span class="nl">padding</span><span class="p">:</span> <span class="m">0px</span> <span class="m">35px</span><span class="p">;</span>
    <span class="nl">max-width</span><span class="p">:</span> <span class="m">1080px</span><span class="p">;</span>
    <span class="nl">margin-left</span><span class="p">:</span> <span class="nb">auto</span><span class="p">;</span>
    <span class="nl">margin-right</span><span class="p">:</span> <span class="nb">auto</span><span class="p">;</span>
    <span class="nl">display</span><span class="p">:</span> <span class="nb">flex</span><span class="p">;</span>
    <span class="nl">flex-direction</span><span class="p">:</span> <span class="nb">row</span><span class="p">;</span>
    <span class="nl">justify-content</span><span class="p">:</span> <span class="nb">space-between</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><img src="/assets/img/html-css-layout-semantic-tags.svg" alt="HTML5 Semantic Tags — Page Structure" width="760" height="340" class="responsive_img" /></p>

<blockquote>
  <p>The key takeaway: semantic tags (<code class="language-plaintext highlighter-rouge">&lt;header&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;footer&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;nav&gt;</code>, <code class="language-plaintext highlighter-rouge">&lt;main&gt;</code>) are not just cosmetic — they communicate the structure of your page to browsers, search engines, and assistive technologies.</p>
</blockquote>

<p>This same header and footer markup is copied to all five HTML files. That repetition is deliberate at this stage: in a real project you would use a template engine or a framework to avoid it, but at the beginner level it is important to understand exactly what is being shared before abstracting it away.</p>

<h2 id="social-media-icons-with-font-awesome">Social Media Icons with Font Awesome</h2>

<p>Plain text links for social profiles look dated. The industry standard is to use icon fonts, and <a href="https://fontawesome.com">Font Awesome</a> is the most widely adopted one.</p>

<p>Font Awesome delivers icons as a web font, which means no image files are needed — each icon is a Unicode character rendered by a font file. The project uses a local copy of Font Awesome to avoid any external dependency. Two CSS files are added to the project:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">assets/css/fontawesome.css</code> — core Font Awesome rules</li>
  <li><code class="language-plaintext highlighter-rouge">assets/css/brands.css</code> — brand icon definitions (Facebook, Twitter, etc.)</li>
</ul>

<p>These are linked from every page alongside <code class="language-plaintext highlighter-rouge">default.css</code>:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"stylesheet"</span> <span class="na">type=</span><span class="s">"text/css"</span> <span class="na">href=</span><span class="s">"assets/css/default.css"</span><span class="nt">&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"stylesheet"</span> <span class="na">type=</span><span class="s">"text/css"</span> <span class="na">href=</span><span class="s">"assets/css/brands.css"</span><span class="nt">&gt;</span>
<span class="nt">&lt;link</span> <span class="na">rel=</span><span class="s">"stylesheet"</span> <span class="na">type=</span><span class="s">"text/css"</span> <span class="na">href=</span><span class="s">"assets/css/fontawesome.css"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>Adding a social icon button to the header is then a matter of one <code class="language-plaintext highlighter-rouge">&lt;a&gt;</code> tag with the right Font Awesome class:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;a</span> <span class="na">aria-label=</span><span class="s">"Code4Projects Facebook page"</span>
   <span class="na">class=</span><span class="s">"social-media-icon social-media-icon-facebook"</span>
   <span class="na">href=</span><span class="s">"https://www.facebook.com/code4projects/"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;span</span> <span class="na">class=</span><span class="s">"fa-brands fa-facebook-f"</span><span class="nt">&gt;&lt;/span&gt;</span>
<span class="nt">&lt;/a&gt;</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">social-media-icon</code> class controls the button size and shape (36×36 px, rounded corners). The <code class="language-plaintext highlighter-rouge">social-media-icon-facebook</code> class gives it the characteristic blue gradient background. The <code class="language-plaintext highlighter-rouge">fa-brands fa-facebook-f</code> classes on the <code class="language-plaintext highlighter-rouge">&lt;span&gt;</code> render the actual icon from the font file.</p>

<h2 id="completing-the-footer">Completing the Footer</h2>

<p>With Font Awesome already integrated, completing the footer is straightforward: reuse the same social icon markup inside <code class="language-plaintext highlighter-rouge">&lt;footer&gt;</code> and add the copyright text next to it.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;footer&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"footer-social-media-group"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"social-media-title"</span><span class="nt">&gt;</span>Follow me on:<span class="nt">&lt;/div&gt;</span>
        <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"social-media-icons"</span><span class="nt">&gt;</span>
            <span class="c">&lt;!-- same icon markup as in the header --&gt;</span>
        <span class="nt">&lt;/div&gt;</span>
    <span class="nt">&lt;/div&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"site-info"</span><span class="nt">&gt;</span>
        Code4Projects, Copyright Salvatore D'Angelo <span class="ni">&amp;copy;</span> 2016-2025.
    <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/footer&gt;</span>
</code></pre></div></div>

<p>The CSS uses Flexbox to stack the social area above the copyright text and keep everything centred. The important point here is <strong>code reuse</strong>: once you understand a pattern (icon button with Font Awesome), you apply it in multiple places without reinventing it.</p>

<h2 id="the-navigation-bar">The Navigation Bar</h2>

<p>Every multi-page site needs a way to move between pages. A navigation bar is the standard solution, and HTML already has a semantic element for it: <code class="language-plaintext highlighter-rouge">&lt;nav&gt;</code>.</p>

<p>The markup uses an unordered list inside <code class="language-plaintext highlighter-rouge">&lt;nav&gt;</code>. At this point the links still point to the five original pages by filename:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"navigation"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;nav&gt;</span>
        <span class="nt">&lt;ul&gt;</span>
            <span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"index.html"</span><span class="nt">&gt;</span>Home<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
            <span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"second_page.html"</span><span class="nt">&gt;</span>Second Page<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
            <span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"third_page.html"</span><span class="nt">&gt;</span>Third Page<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
            <span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"fourth_page.html"</span><span class="nt">&gt;</span>Fourth Page<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
            <span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"fifth_page.html"</span><span class="nt">&gt;</span>Fifth Page<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
        <span class="nt">&lt;/ul&gt;</span>
    <span class="nt">&lt;/nav&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>By default an unordered list is vertical. CSS transforms it into a horizontal blue bar:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">#navigation</span> <span class="p">{</span>
    <span class="nl">background-color</span><span class="p">:</span> <span class="nx">#1D67B2</span><span class="p">;</span>
<span class="p">}</span>

<span class="nt">nav</span> <span class="p">{</span>
    <span class="nl">padding</span><span class="p">:</span> <span class="m">5px</span><span class="p">;</span>
    <span class="nl">max-width</span><span class="p">:</span> <span class="m">1080px</span><span class="p">;</span>
    <span class="nl">margin-left</span><span class="p">:</span> <span class="nb">auto</span><span class="p">;</span>
    <span class="nl">margin-right</span><span class="p">:</span> <span class="nb">auto</span><span class="p">;</span>
<span class="p">}</span>

<span class="nt">nav</span> <span class="nt">ul</span> <span class="nt">li</span> <span class="p">{</span>
    <span class="nl">padding</span><span class="p">:</span> <span class="m">11px</span> <span class="m">35px</span><span class="p">;</span>
    <span class="nl">display</span><span class="p">:</span> <span class="nb">inline-block</span><span class="p">;</span>
<span class="p">}</span>

<span class="nt">nav</span> <span class="nt">ul</span> <span class="nt">li</span> <span class="nt">a</span> <span class="p">{</span>
    <span class="nl">color</span><span class="p">:</span> <span class="nx">#f2f2f2</span><span class="p">;</span>
    <span class="nl">text-decoration</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
    <span class="nl">font-weight</span><span class="p">:</span> <span class="m">700</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">display: inline-block</code> is the key rule: it tells each <code class="language-plaintext highlighter-rouge">&lt;li&gt;</code> to sit side by side instead of stacking. The <code class="language-plaintext highlighter-rouge">#navigation</code> div gets the blue background, and the link colour is set to near-white so it is readable against that background.</p>

<h2 id="building-the-home-page">Building the Home Page</h2>

<p>Inner pages (the five content pages) all share the same basic structure. The home page is different: it needs a visual impact — a hero image, sections with icons, and featured content. This is why a second CSS file, <code class="language-plaintext highlighter-rouge">home.css</code>, is introduced and loaded only by <code class="language-plaintext highlighter-rouge">index.html</code>.</p>

<h3 id="the-hero-area">The Hero Area</h3>

<p>The hero area fills the full width with a background image and overlays a text box on top of it:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">.hero-background</span> <span class="p">{</span>
    <span class="nl">background-image</span><span class="p">:</span> <span class="nf">url</span><span class="p">(</span><span class="s1">"../../assets/img/code4projects-image-hero.webp"</span><span class="p">);</span>
    <span class="nl">background-position</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
    <span class="nl">background-repeat</span><span class="p">:</span> <span class="nb">no-repeat</span><span class="p">;</span>
    <span class="nl">background-size</span><span class="p">:</span> <span class="nb">cover</span><span class="p">;</span>
    <span class="nl">position</span><span class="p">:</span> <span class="nb">relative</span><span class="p">;</span>
<span class="p">}</span>

<span class="nc">.hero-box</span> <span class="p">{</span>
    <span class="nl">padding</span><span class="p">:</span> <span class="m">50px</span> <span class="m">40px</span><span class="p">;</span>
    <span class="nl">display</span><span class="p">:</span> <span class="nb">flex</span><span class="p">;</span>
    <span class="nl">flex-direction</span><span class="p">:</span> <span class="nb">row</span><span class="p">;</span>
    <span class="nl">justify-content</span><span class="p">:</span> <span class="nb">flex-end</span><span class="p">;</span>
<span class="p">}</span>

<span class="nc">.hero-messagebox</span> <span class="p">{</span>
    <span class="nl">padding</span><span class="p">:</span> <span class="m">50px</span> <span class="m">40px</span><span class="p">;</span>
    <span class="nl">max-width</span><span class="p">:</span> <span class="m">400px</span><span class="p">;</span>
    <span class="nl">background-color</span><span class="p">:</span> <span class="nf">RGBA</span><span class="p">(</span><span class="m">139</span><span class="p">,</span> <span class="m">139</span><span class="p">,</span> <span class="m">139</span><span class="p">,</span> <span class="m">0.5</span><span class="p">);</span>
    <span class="nl">border-radius</span><span class="p">:</span> <span class="m">20px</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">background-size: cover</code> scales the image to fill the entire element without distortion. <code class="language-plaintext highlighter-rouge">justify-content: flex-end</code> pushes the text box to the right side. The semi-transparent grey background (<code class="language-plaintext highlighter-rouge">RGBA</code> with 0.5 alpha) makes the text legible over any background image.</p>

<h3 id="the-newsletter-section">The Newsletter Section</h3>

<p>A newsletter sign-up section sits below the feature rows. It uses Flexbox to place an eBook cover image and the sign-up form side by side:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"newsletter-home-box"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"newsletter-home-image"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;img</span> <span class="na">src=</span><span class="s">"assets/img/3D-DockerCover-medium.png"</span>
             <span class="na">alt=</span><span class="s">"Join our Newsletter and Download a Free eBook"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;/div&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"newsletter-home-form"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"newsletter-home-text-title"</span><span class="nt">&gt;</span>Download Free eBook<span class="nt">&lt;/div&gt;</span>
        <span class="nt">&lt;form</span> <span class="na">action=</span><span class="s">"#"</span> <span class="na">method=</span><span class="s">"get"</span> <span class="na">target=</span><span class="s">"_blank"</span><span class="nt">&gt;</span>
            <span class="nt">&lt;input</span> <span class="na">class=</span><span class="s">"newsletter-home-input"</span> <span class="na">name=</span><span class="s">"name"</span>
                   <span class="na">type=</span><span class="s">"text"</span> <span class="na">placeholder=</span><span class="s">"Name"</span> <span class="na">required</span><span class="nt">&gt;</span>
            <span class="nt">&lt;input</span> <span class="na">class=</span><span class="s">"newsletter-home-input"</span> <span class="na">name=</span><span class="s">"email"</span>
                   <span class="na">type=</span><span class="s">"email"</span> <span class="na">placeholder=</span><span class="s">"Email"</span> <span class="na">required</span><span class="nt">&gt;</span>
            <span class="nt">&lt;button</span> <span class="na">type=</span><span class="s">"submit"</span> <span class="na">class=</span><span class="s">"primary"</span><span class="nt">&gt;</span>Download<span class="nt">&lt;/button&gt;</span>
        <span class="nt">&lt;/form&gt;</span>
    <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">type="email"</code> attribute on the email field gives you basic format validation for free — the browser will reject submissions that do not look like an email address, without any JavaScript needed.</p>

<h2 id="making-the-site-responsive">Making the Site Responsive</h2>

<p>A site that works on desktop but breaks on a phone is not a finished site. The final step of Part 2 adds responsive design, which means the layout adapts automatically to the screen size.</p>

<h3 id="the-viewport-meta-tag">The Viewport Meta Tag</h3>

<p>The first change is the simplest: add this <code class="language-plaintext highlighter-rouge">&lt;meta&gt;</code> tag to the <code class="language-plaintext highlighter-rouge">&lt;head&gt;</code> of every page.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;meta</span> <span class="na">name=</span><span class="s">"viewport"</span> <span class="na">content=</span><span class="s">"width=device-width, initial-scale=1"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<p>Without it, mobile browsers scale the page down to fit, rendering it as a shrunken desktop version. With it, the browser uses the actual device width and the layout rules you define via media queries.</p>

<h3 id="media-queries">Media Queries</h3>

<p>The project targets four breakpoints. The diagram below shows how the layout changes at each one:</p>

<p><img src="/assets/img/html-css-layout-responsive.svg" alt="Responsive Layout — Four Breakpoints" width="760" height="360" class="responsive_img" /></p>

<p>The project targets four breakpoints:</p>

<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Width range</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Mobile phones</td>
      <td>≤ 479 px</td>
    </tr>
    <tr>
      <td>Tablets / iPad</td>
      <td>480 px – 767 px</td>
    </tr>
    <tr>
      <td>Laptop / small screen</td>
      <td>768 px – 1023 px</td>
    </tr>
    <tr>
      <td>Desktop</td>
      <td>≥ 1024 px</td>
    </tr>
  </tbody>
</table>

<p>The CSS follows a <strong>mobile-first</strong> approach: the default rules are written for mobile, then overridden for wider screens using <code class="language-plaintext highlighter-rouge">@media screen and (min-width: ...)</code>. For example, the header layout is vertical by default and switches to horizontal row on tablets and above:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* Default: mobile — vertical stack */</span>
<span class="nf">#header-group</span> <span class="p">{</span>
    <span class="nl">display</span><span class="p">:</span> <span class="nb">flex</span><span class="p">;</span>
    <span class="nl">flex-direction</span><span class="p">:</span> <span class="nb">column</span><span class="p">;</span>
    <span class="nl">justify-content</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
<span class="p">}</span>

<span class="c">/* Tablets and wider: horizontal row */</span>
<span class="k">@media</span> <span class="nb">screen</span> <span class="n">and</span> <span class="p">(</span><span class="n">min-width</span><span class="p">:</span> <span class="m">480px</span><span class="p">)</span> <span class="p">{</span>
    <span class="nf">#header-group</span> <span class="p">{</span>
        <span class="nl">flex-direction</span><span class="p">:</span> <span class="nb">row</span><span class="p">;</span>
        <span class="nl">justify-content</span><span class="p">:</span> <span class="nb">space-between</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="the-hamburger-menu">The Hamburger Menu</h3>

<p>On a phone, a horizontal navigation bar does not fit. The solution is a hamburger menu: three horizontal lines that, when tapped, reveal the navigation links in a dropdown.</p>

<p>The implementation uses a hidden checkbox as a CSS-only toggle — no JavaScript required:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"navigation"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;input</span> <span class="na">class=</span><span class="s">"drop-menu-checkbox"</span> <span class="na">type=</span><span class="s">"checkbox"</span> <span class="na">id=</span><span class="s">"drop-menu-checkbox"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;label</span> <span class="na">class=</span><span class="s">"hamburger"</span> <span class="na">for=</span><span class="s">"drop-menu-checkbox"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;span</span> <span class="na">class=</span><span class="s">"hamburger-line"</span><span class="nt">&gt;&lt;/span&gt;</span>
    <span class="nt">&lt;/label&gt;</span>
    <span class="nt">&lt;nav&gt;</span>
        <span class="nt">&lt;ul&gt;</span>
            <span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"index.html"</span><span class="nt">&gt;</span>Home<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
            <span class="c">&lt;!-- ... --&gt;</span>
        <span class="nt">&lt;/ul&gt;</span>
    <span class="nt">&lt;/nav&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>The three hamburger lines are drawn entirely in CSS using <code class="language-plaintext highlighter-rouge">::before</code> and <code class="language-plaintext highlighter-rouge">::after</code> pseudo-elements on <code class="language-plaintext highlighter-rouge">.hamburger-line</code>. When the checkbox is checked, a CSS rule makes the <code class="language-plaintext highlighter-rouge">&lt;ul&gt;</code> visible:</p>

<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* Mobile: nav list hidden by default */</span>
<span class="k">@media</span> <span class="nb">screen</span> <span class="n">and</span> <span class="p">(</span><span class="n">max-width</span><span class="p">:</span> <span class="m">479px</span><span class="p">)</span> <span class="p">{</span>
    <span class="nt">nav</span> <span class="nt">ul</span> <span class="p">{</span>
        <span class="nl">display</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="c">/* Show nav when checkbox is checked */</span>
<span class="nc">.drop-menu-checkbox</span><span class="nd">:checked</span> <span class="o">~</span> <span class="nt">nav</span> <span class="nt">ul</span> <span class="p">{</span>
    <span class="nl">display</span><span class="p">:</span> <span class="nb">block</span><span class="p">;</span>
<span class="p">}</span>

<span class="c">/* Hide hamburger on screens wider than mobile */</span>
<span class="k">@media</span> <span class="nb">screen</span> <span class="n">and</span> <span class="p">(</span><span class="n">min-width</span><span class="p">:</span> <span class="m">480px</span><span class="p">)</span> <span class="p">{</span>
    <span class="nc">.hamburger</span> <span class="p">{</span>
        <span class="nl">display</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">~</code> is the CSS <strong>general sibling combinator</strong>: it selects <code class="language-plaintext highlighter-rouge">nav ul</code> when <code class="language-plaintext highlighter-rouge">.drop-menu-checkbox</code> (the hidden checkbox) is a preceding sibling. Clicking the hamburger label toggles the checkbox, which toggles the menu — no JavaScript at all.</p>

<h2 id="the-final-file-structure">The Final File Structure</h2>

<p>After all Part 2 lessons, the project structure looks like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>lesson-19/
├── index.html
├── second_page.html
├── third_page.html
├── fourth_page.html
├── fifth_page.html
├── favicon.ico
└── assets/
    ├── css/
    │   ├── default.css       ← shared by all pages
    │   ├── home.css          ← home page only
    │   ├── fontawesome.css   ← Font Awesome core
    │   └── brands.css        ← Font Awesome brand icons
    ├── fontawesome/
    │   └── webfonts/         ← .ttf and .woff2 font files
    └── img/                  ← site images
</code></pre></div></div>

<p>The separation between <code class="language-plaintext highlighter-rouge">default.css</code> (shared rules) and <code class="language-plaintext highlighter-rouge">home.css</code> (home-page-only rules) is the key architectural decision. It keeps the shared stylesheet focused and makes it clear what is specific to the home page.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this article we covered:</p>

<ul>
  <li>How to use <code class="language-plaintext highlighter-rouge">&lt;header&gt;</code> and <code class="language-plaintext highlighter-rouge">&lt;footer&gt;</code> semantic tags for a consistent page structure</li>
  <li>How to integrate Font Awesome locally to add scalable social media icons</li>
  <li>How to build a horizontal navigation bar from a <code class="language-plaintext highlighter-rouge">&lt;ul&gt;</code> with <code class="language-plaintext highlighter-rouge">display: inline-block</code></li>
  <li>How to structure a home page with a CSS background image hero, circular icons, and feature sections</li>
  <li>How to add a newsletter sign-up form with styled <code class="language-plaintext highlighter-rouge">&lt;input&gt;</code> fields</li>
  <li>How to apply responsive design with the viewport meta tag, media queries, and a CSS-only hamburger menu</li>
</ul>

<p>The <a href="/building-complete-website-pages-forms-gallery/">next article</a> builds out the real content pages: About Me, Start Here, Resources, a Contacts form, and an interactive photo gallery — introducing HTML forms, CSS Grid, and the first JavaScript in the series.</p>]]></content><author><name>Salvatore D&apos;Angelo</name></author><category term="Programming" /><category term="programming" /><category term="html" /><category term="css" /><summary type="html"><![CDATA[Turn your plain HTML site into a professional layout — styled header, footer, navigation bar, home page with hero image, and a mobile-friendly hamburger menu.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://code4projects.org/assets/img/html-css-layout.svg" /><media:content medium="image" url="https://code4projects.org/assets/img/html-css-layout.svg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Async, Background Tasks, and Error Handling in FastAPI</title><link href="https://code4projects.org/fastapi-async-background-tasks-and-error-handling/" rel="alternate" type="text/html" title="Async, Background Tasks, and Error Handling in FastAPI" /><published>2026-10-06T00:00:00+00:00</published><updated>2026-10-06T00:00:00+00:00</updated><id>https://code4projects.org/fastapi-async-background-tasks-and-error-handling</id><content type="html" xml:base="https://code4projects.org/fastapi-async-background-tasks-and-error-handling/"><![CDATA[<p><img src="/assets/img/fastapi-async-background-tasks-and-error-handling-hero.svg" alt="Async, Background Tasks, and Error Handling in FastAPI" width="760" height="400" class="responsive_img" /></p>

<h2 id="introduction">Introduction</h2>

<p>In the <a href="/layered-architecture-with-fastapi-routers-services-and-repositories/">previous article</a> we organized <a href="https://github.com/sasadangelo/fasturl">FastURL</a> — our open-source URL shortener — into clean layers: routers, services, repositories, and a dependency injection system that wires them together. The structure is in place; now we need to make it behave correctly at runtime.</p>

<p>Three things have to work right in any production FastAPI application that does non-trivial work:</p>

<ul>
  <li><strong>Async I/O</strong> must be used correctly so the event loop is never blocked.</li>
  <li><strong>Background tasks</strong> must fire without delaying the HTTP response.</li>
  <li><strong>Error handling</strong> must produce a predictable, machine-readable envelope for every possible failure.</li>
</ul>

<p>This article covers all three, using FastURL as the concrete project.</p>

<p>You should read this article if:</p>

<ul>
  <li>You understand async/await in Python but are unsure which handlers in FastAPI should be <code class="language-plaintext highlighter-rouge">async def</code> and which should be <code class="language-plaintext highlighter-rouge">def</code>.</li>
  <li>You want to implement fire-and-forget background work in FastAPI without introducing an external broker.</li>
  <li>You want a single, consistent error envelope across Pydantic validation failures, domain exceptions, and unhandled errors.</li>
</ul>

<p>This article is part of the <a href="/how-to-set-up-your-next-python-project/">Modern Python Application</a> series and the FastURL sub-series.</p>

<h2 id="async-io-in-fastapi-async-def-vs-def">Async I/O in FastAPI: <code class="language-plaintext highlighter-rouge">async def</code> vs <code class="language-plaintext highlighter-rouge">def</code></h2>

<p>Think of a highway with a single lane: every vehicle must wait for the one in front to clear before it can move. A blocking route handler is that stuck vehicle — while it waits for a database query or an HTTP call to return, no other request can be processed. FastAPI runs on an <a href="/async-and-event-loop-in-python-asyncio-in-practice/">async event loop</a> — the same model we covered in depth in the asyncio article — so every <code class="language-plaintext highlighter-rouge">await</code> is a legal overtaking lane: the handler suspends, the event loop runs something else, and the handler resumes when the I/O is ready.</p>

<p>The rule is simple:</p>

<blockquote>
  <p>If a handler calls anything that involves I/O — database queries, outbound HTTP, file reads — declare it <code class="language-plaintext highlighter-rouge">async def</code> and <code class="language-plaintext highlighter-rouge">await</code> every call. If it does only CPU work, use plain <code class="language-plaintext highlighter-rouge">def</code>.</p>
</blockquote>

<p>FastAPI handles both correctly. A plain <code class="language-plaintext highlighter-rouge">def</code> route is run in a thread pool so it does not block the event loop. An <code class="language-plaintext highlighter-rouge">async def</code> route runs directly on the loop. The mistake to avoid is mixing the two: an <code class="language-plaintext highlighter-rouge">async def</code> that calls a blocking library (like <code class="language-plaintext highlighter-rouge">requests</code> or synchronous SQLAlchemy) blocks the event loop entirely, stalling every other request in the process. If you are new to the concurrency model underlying this, the <a href="/concurrency-in-python-threads-processes-and-the-event-loop/">concurrency in Python</a> article covers threads, processes, and the event loop side by side.</p>

<p>The table below maps the most common blocking operations to their async replacements:</p>

<table>
  <thead>
    <tr>
      <th><strong>Operation</strong></th>
      <th><strong>Blocking (do not use in <code class="language-plaintext highlighter-rouge">async def</code>)</strong></th>
      <th><strong>Async replacement</strong></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>HTTP requests</strong></td>
      <td><code class="language-plaintext highlighter-rouge">requests.get(url)</code></td>
      <td><code class="language-plaintext highlighter-rouge">await httpx.AsyncClient().get(url)</code> or <code class="language-plaintext highlighter-rouge">aiohttp</code></td>
    </tr>
    <tr>
      <td><strong>Database queries</strong></td>
      <td><code class="language-plaintext highlighter-rouge">sqlalchemy.Session.execute(...)</code></td>
      <td><code class="language-plaintext highlighter-rouge">await sqlalchemy.ext.asyncio.AsyncSession.execute(...)</code></td>
    </tr>
    <tr>
      <td><strong>File I/O</strong></td>
      <td><code class="language-plaintext highlighter-rouge">open(path).read()</code></td>
      <td><code class="language-plaintext highlighter-rouge">await aiofiles.open(path)</code> (<code class="language-plaintext highlighter-rouge">aiofiles</code> package)</td>
    </tr>
    <tr>
      <td><strong>Sleep / delay</strong></td>
      <td><code class="language-plaintext highlighter-rouge">time.sleep(n)</code></td>
      <td><code class="language-plaintext highlighter-rouge">await asyncio.sleep(n)</code></td>
    </tr>
    <tr>
      <td><strong>DNS / socket</strong></td>
      <td><code class="language-plaintext highlighter-rouge">socket.getaddrinfo(...)</code></td>
      <td><code class="language-plaintext highlighter-rouge">await asyncio.get_event_loop().getaddrinfo(...)</code></td>
    </tr>
    <tr>
      <td><strong>Subprocess</strong></td>
      <td><code class="language-plaintext highlighter-rouge">subprocess.run(...)</code></td>
      <td><code class="language-plaintext highlighter-rouge">await asyncio.create_subprocess_exec(...)</code></td>
    </tr>
    <tr>
      <td><strong>Redis</strong></td>
      <td><code class="language-plaintext highlighter-rouge">redis.Redis().get(key)</code></td>
      <td><code class="language-plaintext highlighter-rouge">await redis.asyncio.Redis().get(key)</code> (<code class="language-plaintext highlighter-rouge">redis-py</code> ≥ 4.2)</td>
    </tr>
    <tr>
      <td><strong>PostgreSQL</strong></td>
      <td><code class="language-plaintext highlighter-rouge">psycopg2.connect(...)</code></td>
      <td><code class="language-plaintext highlighter-rouge">asyncpg</code> or <code class="language-plaintext highlighter-rouge">psycopg</code> (v3 async)</td>
    </tr>
  </tbody>
</table>

<p>If an existing library has no async version, wrap the blocking call in <code class="language-plaintext highlighter-rouge">await asyncio.get_event_loop().run_in_executor(None, blocking_fn)</code> to offload it to a thread pool without stalling the loop. The <a href="https://docs.python.org/3/library/asyncio.html">asyncio docs</a> cover the full executor API.</p>

<p>In FastURL every route handler is <code class="language-plaintext highlighter-rouge">async def</code> because every one of them calls either the async repository (SQLAlchemy <code class="language-plaintext highlighter-rouge">AsyncSession</code>) or the background inspector (<code class="language-plaintext highlighter-rouge">httpx.AsyncClient</code>). Here is the <code class="language-plaintext highlighter-rouge">create_link</code> handler in full:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@router.post</span><span class="p">(</span>
    <span class="n">path</span><span class="o">=</span><span class="sh">""</span><span class="p">,</span>
    <span class="n">response_model</span><span class="o">=</span><span class="n">LinkResponse</span><span class="p">,</span>
    <span class="n">status_code</span><span class="o">=</span><span class="n">status</span><span class="p">.</span><span class="n">HTTP_201_CREATED</span><span class="p">,</span>
<span class="p">)</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">create_link</span><span class="p">(</span>
    <span class="n">body</span><span class="p">:</span> <span class="n">LinkCreateRequest</span><span class="p">,</span>
    <span class="n">service</span><span class="p">:</span> <span class="n">LinkServiceDep</span><span class="p">,</span>
    <span class="n">http_client</span><span class="p">:</span> <span class="n">HTTPClientDep</span><span class="p">,</span>
    <span class="n">background_tasks</span><span class="p">:</span> <span class="n">BackgroundTasks</span><span class="p">,</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="n">LinkResponse</span><span class="p">:</span>
    <span class="n">response</span><span class="p">:</span> <span class="n">LinkResponse</span> <span class="o">=</span> <span class="k">await</span> <span class="n">service</span><span class="p">.</span><span class="nf">create_link</span><span class="p">(</span>
        <span class="n">target_url</span><span class="o">=</span><span class="nf">str</span><span class="p">(</span><span class="n">body</span><span class="p">.</span><span class="n">target_url</span><span class="p">),</span>
        <span class="n">custom_code</span><span class="o">=</span><span class="n">body</span><span class="p">.</span><span class="n">custom_code</span><span class="p">,</span>
        <span class="n">expires_at</span><span class="o">=</span><span class="n">body</span><span class="p">.</span><span class="n">expires_at</span><span class="p">,</span>
    <span class="p">)</span>
    <span class="n">background_tasks</span><span class="p">.</span><span class="nf">add_task</span><span class="p">(</span>
        <span class="n">func</span><span class="o">=</span><span class="n">inspect_link</span><span class="p">,</span>
        <span class="n">code</span><span class="o">=</span><span class="n">response</span><span class="p">.</span><span class="n">code</span><span class="p">,</span>
        <span class="n">target_url</span><span class="o">=</span><span class="n">response</span><span class="p">.</span><span class="n">target_url</span><span class="p">,</span>
        <span class="n">http_client</span><span class="o">=</span><span class="n">http_client</span><span class="p">,</span>
        <span class="n">repository</span><span class="o">=</span><span class="n">service</span><span class="p">.</span><span class="n">_repository</span><span class="p">,</span>
    <span class="p">)</span>
    <span class="k">return</span> <span class="n">response</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">await service.create_link(...)</code> yields control to the event loop while the database insert is in flight. By the time the <code class="language-plaintext highlighter-rouge">return</code> statement executes, the link is persisted and the response is ready. The background inspection — which involves an outbound HTTP call to the target URL — is dispatched via <code class="language-plaintext highlighter-rouge">BackgroundTasks</code> and never blocks the response. More on that in the next section.</p>

<h3 id="the-shared-httpxasyncclient">The shared <code class="language-plaintext highlighter-rouge">httpx.AsyncClient</code></h3>

<p>The <code class="language-plaintext highlighter-rouge">http_client</code> parameter above comes from <code class="language-plaintext highlighter-rouge">app.state</code>. Creating a new <code class="language-plaintext highlighter-rouge">httpx.AsyncClient</code> per request is the most common mistake in async FastAPI code: each new client opens a fresh connection pool, establishes a TCP handshake, and discards both when the request ends. With any meaningful traffic, you get hundreds of redundant connections per second. The <a href="https://www.python-httpx.org/advanced/connection-pools/">httpx docs on connection pooling</a> explain the mechanics in detail.</p>

<p>The right pattern is one shared client per application, created at startup and closed at shutdown. FastAPI’s <code class="language-plaintext highlighter-rouge">lifespan</code> context manager is exactly the right place for it:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">contextlib</span> <span class="kn">import</span> <span class="n">asynccontextmanager</span>
<span class="kn">from</span> <span class="n">collections.abc</span> <span class="kn">import</span> <span class="n">AsyncIterator</span>
<span class="kn">import</span> <span class="n">httpx</span>
<span class="kn">from</span> <span class="n">fastapi</span> <span class="kn">import</span> <span class="n">FastAPI</span>

<span class="nd">@asynccontextmanager</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">lifespan</span><span class="p">(</span><span class="n">app</span><span class="p">:</span> <span class="n">FastAPI</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">AsyncIterator</span><span class="p">[</span><span class="bp">None</span><span class="p">]:</span>
    <span class="c1"># --- Startup ---
</span>    <span class="n">app</span><span class="p">.</span><span class="n">state</span><span class="p">.</span><span class="n">http_client</span> <span class="o">=</span> <span class="n">httpx</span><span class="p">.</span><span class="nc">AsyncClient</span><span class="p">(</span>
        <span class="n">timeout</span><span class="o">=</span><span class="n">httpx</span><span class="p">.</span><span class="nc">Timeout</span><span class="p">(</span><span class="mf">10.0</span><span class="p">),</span>
        <span class="n">follow_redirects</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
        <span class="n">max_redirects</span><span class="o">=</span><span class="mi">5</span><span class="p">,</span>
        <span class="n">headers</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">User-Agent</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">FastURL-Inspector/0.1</span><span class="sh">"</span><span class="p">},</span>
    <span class="p">)</span>
    <span class="k">yield</span>
    <span class="c1"># --- Shutdown ---
</span>    <span class="k">await</span> <span class="n">app</span><span class="p">.</span><span class="n">state</span><span class="p">.</span><span class="n">http_client</span><span class="p">.</span><span class="nf">aclose</span><span class="p">()</span>

<span class="n">app</span> <span class="o">=</span> <span class="nc">FastAPI</span><span class="p">(</span><span class="n">lifespan</span><span class="o">=</span><span class="n">lifespan</span><span class="p">)</span>
</code></pre></div></div>

<p>Breaking down the <code class="language-plaintext highlighter-rouge">httpx.AsyncClient</code> options:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">timeout=httpx.Timeout(10.0)</code> — caps the full request lifecycle (connect + read) at 10 seconds; without a timeout a hanging target URL would hold an open connection indefinitely</li>
  <li><code class="language-plaintext highlighter-rouge">follow_redirects=True</code> — the inspector must reach the final destination to extract accurate metadata</li>
  <li><code class="language-plaintext highlighter-rouge">max_redirects=5</code> — prevents infinite redirect loops from consuming the client</li>
  <li><code class="language-plaintext highlighter-rouge">headers={"User-Agent": "..."}</code> — many servers return <code class="language-plaintext highlighter-rouge">403</code> for requests that look like anonymous bots; a descriptive <code class="language-plaintext highlighter-rouge">User-Agent</code> prevents that class of silent failure</li>
</ul>

<p>The client is retrieved inside a FastAPI dependency and injected into route handlers via <code class="language-plaintext highlighter-rouge">HTTPClientDep</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">_get_http_client</span><span class="p">(</span><span class="n">request</span><span class="p">:</span> <span class="n">Request</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">httpx</span><span class="p">.</span><span class="n">AsyncClient</span><span class="p">:</span>
    <span class="k">return</span> <span class="n">request</span><span class="p">.</span><span class="n">app</span><span class="p">.</span><span class="n">state</span><span class="p">.</span><span class="n">http_client</span>

<span class="n">HTTPClientDep</span><span class="p">:</span> <span class="n">TypeAlias</span> <span class="o">=</span> <span class="n">Annotated</span><span class="p">[</span><span class="n">httpx</span><span class="p">.</span><span class="n">AsyncClient</span><span class="p">,</span> <span class="nc">Depends</span><span class="p">(</span><span class="n">_get_http_client</span><span class="p">)]</span>
</code></pre></div></div>

<blockquote>
  <p>One <code class="language-plaintext highlighter-rouge">httpx.AsyncClient</code> per application, opened in <code class="language-plaintext highlighter-rouge">lifespan</code>, closed on shutdown. Never create a client inside a route handler or a background task.</p>
</blockquote>

<p>The full implementation is in <a href="https://github.com/sasadangelo/fasturl/blob/main/src/fasturl/main.py">main.py</a>.</p>

<h3 id="the-shared-database-engine">The shared database engine</h3>

<p>The same principle applies to the SQLAlchemy async engine. The engine owns the <strong>connection pool</strong> — the set of persistent database connections that are checked out per request and returned when the session closes. Creating a new engine per request would open a fresh pool, perform the SQLite handshake, and discard both at the end of the request, identical to the <code class="language-plaintext highlighter-rouge">httpx.AsyncClient</code> mistake. We first introduced SQLAlchemy’s async session in the <a href="/database-layer-with-sqlalchemy-in-a-layered-python-architecture/">database layer article</a> — see that article for the full ORM setup. The <a href="https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html">SQLAlchemy async docs</a> cover engine and session lifecycle in depth.</p>

<p>The engine and session factory are created once at startup and disposed at shutdown inside two helpers, <code class="language-plaintext highlighter-rouge">init_db()</code> and <code class="language-plaintext highlighter-rouge">close_db()</code>, defined in <a href="https://github.com/sasadangelo/fasturl/blob/main/src/fasturl/core/database.py">database.py</a>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># database.py
</span><span class="kn">from</span> <span class="n">sqlalchemy.ext.asyncio</span> <span class="kn">import</span> <span class="p">(</span>
    <span class="n">AsyncEngine</span><span class="p">,</span>
    <span class="n">AsyncSession</span><span class="p">,</span>
    <span class="n">async_sessionmaker</span><span class="p">,</span>
    <span class="n">create_async_engine</span><span class="p">,</span>
<span class="p">)</span>

<span class="n">_engine</span><span class="p">:</span> <span class="n">AsyncEngine</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span>
<span class="n">_session_factory</span><span class="p">:</span> <span class="n">async_sessionmaker</span><span class="p">[</span><span class="n">AsyncSession</span><span class="p">]</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">init_db</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="k">global</span> <span class="n">_engine</span><span class="p">,</span> <span class="n">_session_factory</span>
    <span class="n">_engine</span> <span class="o">=</span> <span class="nf">create_async_engine</span><span class="p">(</span><span class="n">url</span><span class="o">=</span><span class="n">settings</span><span class="p">.</span><span class="n">database</span><span class="p">.</span><span class="n">url</span><span class="p">,</span> <span class="n">echo</span><span class="o">=</span><span class="n">settings</span><span class="p">.</span><span class="n">database</span><span class="p">.</span><span class="n">echo</span><span class="p">)</span>
    <span class="n">_session_factory</span> <span class="o">=</span> <span class="nf">async_sessionmaker</span><span class="p">(</span><span class="n">bind</span><span class="o">=</span><span class="n">_engine</span><span class="p">,</span> <span class="n">class_</span><span class="o">=</span><span class="n">AsyncSession</span><span class="p">,</span> <span class="n">expire_on_commit</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="k">async</span> <span class="k">with</span> <span class="n">_engine</span><span class="p">.</span><span class="nf">begin</span><span class="p">()</span> <span class="k">as</span> <span class="n">conn</span><span class="p">:</span>
        <span class="k">await</span> <span class="n">conn</span><span class="p">.</span><span class="nf">run_sync</span><span class="p">(</span><span class="n">Base</span><span class="p">.</span><span class="n">metadata</span><span class="p">.</span><span class="n">create_all</span><span class="p">)</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">close_db</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="k">global</span> <span class="n">_engine</span>
    <span class="k">if</span> <span class="n">_engine</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
        <span class="k">await</span> <span class="n">_engine</span><span class="p">.</span><span class="nf">dispose</span><span class="p">()</span>
        <span class="n">_engine</span> <span class="o">=</span> <span class="bp">None</span>
</code></pre></div></div>

<p>Both are called from the <code class="language-plaintext highlighter-rouge">lifespan</code> context manager in <a href="https://github.com/sasadangelo/fasturl/blob/main/src/fasturl/main.py">main.py</a>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># main.py
</span><span class="k">await</span> <span class="nf">init_db</span><span class="p">()</span>   <span class="c1"># startup
</span><span class="k">yield</span>
<span class="k">await</span> <span class="nf">close_db</span><span class="p">()</span>  <span class="c1"># shutdown — engine.dispose() flushes and closes all pooled connections
</span></code></pre></div></div>

<p>The per-request session is a separate concern. A session is <strong>not</strong> safe to share across requests: it carries per-request transaction state and is not coroutine-safe. The <code class="language-plaintext highlighter-rouge">get_db</code> dependency opens a fresh session from the shared factory for every request and closes it automatically when the request ends:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">def</span> <span class="nf">get_db</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="n">AsyncIterator</span><span class="p">[</span><span class="n">AsyncSession</span><span class="p">]:</span>
    <span class="k">async</span> <span class="k">with</span> <span class="nf">_session_factory</span><span class="p">()</span> <span class="k">as</span> <span class="n">session</span><span class="p">:</span>
        <span class="k">yield</span> <span class="n">session</span>
</code></pre></div></div>

<p>Calling <code class="language-plaintext highlighter-rouge">_session_factory()</code> is cheap — it checks out a connection from the pool that already exists in the engine; it does not open a new one. The pool is reused; only the session object is fresh.</p>

<p>This creates an important asymmetry between the two shared resources:</p>

<table>
  <thead>
    <tr>
      <th>Resource</th>
      <th>Scope</th>
      <th>Reason</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">httpx.AsyncClient</code></td>
      <td>Application — one instance</td>
      <td>Stateless; connection pool is coroutine-safe</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">AsyncEngine</code></td>
      <td>Application — one instance</td>
      <td>Stateless; connection pool is coroutine-safe</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">async_sessionmaker</code></td>
      <td>Application — one instance</td>
      <td>Cheap factory; just wraps the engine</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">AsyncSession</code></td>
      <td>Request — one per request</td>
      <td>Carries transaction state; not safe to share</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p>One <code class="language-plaintext highlighter-rouge">AsyncEngine</code> per application, opened in <code class="language-plaintext highlighter-rouge">lifespan</code>, disposed on shutdown. One <code class="language-plaintext highlighter-rouge">AsyncSession</code> per request, opened by <code class="language-plaintext highlighter-rouge">get_db</code>, closed after the request. Never share a session across requests.</p>
</blockquote>

<p>The full implementation is in <a href="https://github.com/sasadangelo/fasturl/blob/main/src/fasturl/core/database.py">database.py</a>.</p>

<p><img src="/assets/img/fastapi-async-background-tasks-and-error-handling-lifespan.svg" alt="Application Lifespan — Shared Resource Lifecycle" width="760" height="420" class="responsive_img" /></p>

<h2 id="background-tasks-fire-and-forget-without-a-broker">Background Tasks: Fire-and-Forget Without a Broker</h2>

<p>When a new link is created, FastURL must immediately return a <code class="language-plaintext highlighter-rouge">201 Created</code> response to the caller. But it also needs to check the target URL’s health, record its HTTP status code and latency, and extract the HTML title and OpenGraph metadata. These operations take hundreds of milliseconds — far too long to include in the response cycle.</p>

<p>FastAPI’s <code class="language-plaintext highlighter-rouge">BackgroundTasks</code> solves this without any external infrastructure. A <code class="language-plaintext highlighter-rouge">BackgroundTask</code> is a coroutine (or a plain function) that FastAPI runs after the response has been sent to the client. The route handler returns immediately; the background work executes in the same event loop, after Starlette finishes streaming the response bytes.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">background_tasks</span><span class="p">.</span><span class="nf">add_task</span><span class="p">(</span>
    <span class="n">func</span><span class="o">=</span><span class="n">inspect_link</span><span class="p">,</span>
    <span class="n">code</span><span class="o">=</span><span class="n">response</span><span class="p">.</span><span class="n">code</span><span class="p">,</span>
    <span class="n">target_url</span><span class="o">=</span><span class="n">response</span><span class="p">.</span><span class="n">target_url</span><span class="p">,</span>
    <span class="n">http_client</span><span class="o">=</span><span class="n">http_client</span><span class="p">,</span>
    <span class="n">repository</span><span class="o">=</span><span class="n">service</span><span class="p">.</span><span class="n">_repository</span><span class="p">,</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Breaking down the arguments:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">func=inspect_link</code> — the coroutine function to run; FastAPI detects <code class="language-plaintext highlighter-rouge">async def</code> and awaits it automatically</li>
  <li><code class="language-plaintext highlighter-rouge">code</code> and <code class="language-plaintext highlighter-rouge">target_url</code> — positional arguments forwarded to <code class="language-plaintext highlighter-rouge">inspect_link</code></li>
  <li><code class="language-plaintext highlighter-rouge">http_client</code> — the shared <code class="language-plaintext highlighter-rouge">httpx.AsyncClient</code> from <code class="language-plaintext highlighter-rouge">app.state</code>; the background task must reuse it, not create its own</li>
  <li><code class="language-plaintext highlighter-rouge">repository=service._repository</code> — the <code class="language-plaintext highlighter-rouge">LinkRepository</code> instance, already bound to the current database session via dependency injection</li>
</ul>

<h3 id="what-inspect_link-does">What <code class="language-plaintext highlighter-rouge">inspect_link</code> does</h3>

<p><code class="language-plaintext highlighter-rouge">inspect_link</code> is a pure async function in <a href="https://github.com/sasadangelo/fasturl/blob/main/src/fasturl/services/inspector_service.py">inspector_service.py</a>. It has zero FastAPI imports — it knows nothing about HTTP requests, response models, or routing. This is the key constraint that keeps the service layer testable:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">def</span> <span class="nf">inspect_link</span><span class="p">(</span>
    <span class="n">code</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
    <span class="n">target_url</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
    <span class="n">http_client</span><span class="p">:</span> <span class="n">httpx</span><span class="p">.</span><span class="n">AsyncClient</span><span class="p">,</span>
    <span class="n">repository</span><span class="p">:</span> <span class="n">LinkRepository</span><span class="p">,</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="n">status</span> <span class="o">=</span> <span class="sh">"</span><span class="s">unreachable</span><span class="sh">"</span>
    <span class="n">http_status_code</span><span class="p">:</span> <span class="nb">int</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span>
    <span class="n">latency_ms</span><span class="p">:</span> <span class="nb">float</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span>
    <span class="n">title</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span>
    <span class="n">description</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span>
    <span class="n">image_url</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span>

    <span class="n">start</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="nf">monotonic</span><span class="p">()</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="n">response</span> <span class="o">=</span> <span class="k">await</span> <span class="n">http_client</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="n">target_url</span><span class="p">)</span>
        <span class="n">latency_ms</span> <span class="o">=</span> <span class="nf">round</span><span class="p">((</span><span class="n">time</span><span class="p">.</span><span class="nf">monotonic</span><span class="p">()</span> <span class="o">-</span> <span class="n">start</span><span class="p">)</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>
        <span class="n">http_status_code</span> <span class="o">=</span> <span class="n">response</span><span class="p">.</span><span class="n">status_code</span>

        <span class="k">if</span> <span class="n">response</span><span class="p">.</span><span class="n">is_success</span> <span class="ow">or</span> <span class="n">response</span><span class="p">.</span><span class="n">is_redirect</span><span class="p">:</span>
            <span class="n">status</span> <span class="o">=</span> <span class="sh">"</span><span class="s">active</span><span class="sh">"</span>
            <span class="n">html</span> <span class="o">=</span> <span class="n">response</span><span class="p">.</span><span class="n">text</span>
            <span class="n">title</span> <span class="o">=</span> <span class="nf">_extract_title</span><span class="p">(</span><span class="n">html</span><span class="p">)</span>
            <span class="n">description</span> <span class="o">=</span> <span class="nf">_extract_description</span><span class="p">(</span><span class="n">html</span><span class="p">)</span>
            <span class="n">image_url</span> <span class="o">=</span> <span class="nf">_extract_image_url</span><span class="p">(</span><span class="n">html</span><span class="p">)</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="n">status</span> <span class="o">=</span> <span class="sh">"</span><span class="s">unreachable</span><span class="sh">"</span>

    <span class="k">except</span> <span class="n">httpx</span><span class="p">.</span><span class="n">TimeoutException</span><span class="p">:</span>
        <span class="n">latency_ms</span> <span class="o">=</span> <span class="nf">round</span><span class="p">((</span><span class="n">time</span><span class="p">.</span><span class="nf">monotonic</span><span class="p">()</span> <span class="o">-</span> <span class="n">start</span><span class="p">)</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>

    <span class="k">except</span> <span class="n">httpx</span><span class="p">.</span><span class="n">RequestError</span> <span class="k">as</span> <span class="n">exc</span><span class="p">:</span>
        <span class="n">latency_ms</span> <span class="o">=</span> <span class="nf">round</span><span class="p">((</span><span class="n">time</span><span class="p">.</span><span class="nf">monotonic</span><span class="p">()</span> <span class="o">-</span> <span class="n">start</span><span class="p">)</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span>

    <span class="k">await</span> <span class="n">repository</span><span class="p">.</span><span class="nf">update_inspection</span><span class="p">(</span>
        <span class="n">code</span><span class="p">,</span>
        <span class="n">status</span><span class="o">=</span><span class="n">status</span><span class="p">,</span>
        <span class="n">http_status_code</span><span class="o">=</span><span class="n">http_status_code</span><span class="p">,</span>
        <span class="n">latency_ms</span><span class="o">=</span><span class="n">latency_ms</span><span class="p">,</span>
        <span class="n">title</span><span class="o">=</span><span class="n">title</span><span class="p">,</span>
        <span class="n">description</span><span class="o">=</span><span class="n">description</span><span class="p">,</span>
        <span class="n">image_url</span><span class="o">=</span><span class="n">image_url</span><span class="p">,</span>
    <span class="p">)</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">try/except</code> block covers two failure modes: <code class="language-plaintext highlighter-rouge">httpx.TimeoutException</code> for deadline expiry and <code class="language-plaintext highlighter-rouge">httpx.RequestError</code> for DNS failures, refused connections, and similar network-level errors. In both cases the <code class="language-plaintext highlighter-rouge">status</code> remains <code class="language-plaintext highlighter-rouge">"unreachable"</code> and the inspection result is persisted so the caller can see the link was checked but unreachable — rather than leaving the status permanently at <code class="language-plaintext highlighter-rouge">"pending_analysis"</code>.</p>

<p>The metadata extraction uses three lightweight regex helpers — <code class="language-plaintext highlighter-rouge">_extract_title</code>, <code class="language-plaintext highlighter-rouge">_extract_description</code>, <code class="language-plaintext highlighter-rouge">_extract_image_url</code> — that parse the raw HTML string without adding a full parser dependency. They are pure synchronous functions: by the time they run, the HTTP response body is already in memory, so there is no I/O to await.</p>

<p><img src="/assets/img/fastapi-async-background-tasks-and-error-handling-sequence.svg" alt="Background Task Sequence — Fire and Forget" width="760" height="460" class="responsive_img" /></p>

<h3 id="backgroundtasks-vs-an-external-broker"><code class="language-plaintext highlighter-rouge">BackgroundTasks</code> vs an external broker</h3>

<p><code class="language-plaintext highlighter-rouge">BackgroundTasks</code> is not a replacement for a job queue like Celery, RQ, or a message broker. The comparison is worth making explicit:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th><strong><code class="language-plaintext highlighter-rouge">BackgroundTasks</code></strong></th>
      <th><strong>External broker (Celery / RQ / etc.)</strong></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Infrastructure</strong></td>
      <td>None — runs in the same process</td>
      <td>Requires Redis, RabbitMQ, or similar</td>
    </tr>
    <tr>
      <td><strong>Durability</strong></td>
      <td>Lost if the process crashes</td>
      <td>Persisted; survives restarts</td>
    </tr>
    <tr>
      <td><strong>Retries</strong></td>
      <td>Manual — must be coded explicitly</td>
      <td>Built-in retry policies</td>
    </tr>
    <tr>
      <td><strong>Scalability</strong></td>
      <td>Bounded by the web process</td>
      <td>Dedicated worker pool</td>
    </tr>
    <tr>
      <td><strong>Best for</strong></td>
      <td>Low-latency, non-critical side effects</td>
      <td>Critical work that must not be lost</td>
    </tr>
  </tbody>
</table>

<p>FastURL’s inspection task is a good fit for <code class="language-plaintext highlighter-rouge">BackgroundTasks</code>: losing an inspection on a crash is acceptable — the client can re-trigger it via <code class="language-plaintext highlighter-rouge">POST /api/v1/links/{code}/inspect</code>. A payment processing step or an email confirmation would not be acceptable candidates.</p>

<p><img src="/assets/img/fastapi-async-background-tasks-and-error-handling-broker.svg" alt="BackgroundTasks vs External Broker" width="760" height="380" class="responsive_img" /></p>

<h2 id="error-handling-one-envelope-for-every-failure">Error Handling: One Envelope for Every Failure</h2>

<p>A client that calls a production API must be able to branch on the response without parsing human-readable text. The FastURL error contract guarantees this with a single JSON shape for every failure. The <a href="https://fastapi.tiangolo.com/tutorial/handling-errors/">FastAPI exception handlers docs</a> describe the default behaviour we are overriding here:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"success"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w">
  </span><span class="nl">"error"</span><span class="p">:</span><span class="w"> </span><span class="s2">"LINK_NOT_FOUND"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"details"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="s2">"code -&gt; No link found with code 'aB3x9zK'"</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">success</code> — always <code class="language-plaintext highlighter-rouge">false</code> on error; useful for clients that check this flag before parsing the body</li>
  <li><code class="language-plaintext highlighter-rouge">error</code> — a machine-readable short code; clients branch on this field</li>
  <li><code class="language-plaintext highlighter-rouge">details</code> — a list of human-readable, field-level explanations; used for logging and developer messages</li>
</ul>

<p>Three global handlers registered in <code class="language-plaintext highlighter-rouge">main.py</code> cover every failure path.</p>

<h3 id="handler-1-pydantic-validation-failures-400">Handler 1: Pydantic validation failures (400)</h3>

<p>When FastAPI receives a request body or query parameter that fails Pydantic validation, it raises <code class="language-plaintext highlighter-rouge">RequestValidationError</code>. The default FastAPI behaviour returns a <code class="language-plaintext highlighter-rouge">422 Unprocessable Entity</code> — but <code class="language-plaintext highlighter-rouge">422</code> is rarely what API clients expect for bad input. FastURL overrides this to <code class="language-plaintext highlighter-rouge">400 Bad Request</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@app.exception_handler</span><span class="p">(</span><span class="n">RequestValidationError</span><span class="p">)</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">validation_exception_handler</span><span class="p">(</span><span class="n">request</span><span class="p">:</span> <span class="n">Request</span><span class="p">,</span> <span class="n">exc</span><span class="p">:</span> <span class="n">RequestValidationError</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">JSONResponse</span><span class="p">:</span>
    <span class="n">error_details</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">e</span> <span class="ow">in</span> <span class="n">exc</span><span class="p">.</span><span class="nf">errors</span><span class="p">():</span>
        <span class="n">loc</span> <span class="o">=</span> <span class="sh">"</span><span class="s"> -&gt; </span><span class="sh">"</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="nf">str</span><span class="p">(</span><span class="n">x</span><span class="p">)</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="n">e</span><span class="p">[</span><span class="sh">"</span><span class="s">loc</span><span class="sh">"</span><span class="p">])</span>
        <span class="n">msg</span> <span class="o">=</span> <span class="n">e</span><span class="p">[</span><span class="sh">"</span><span class="s">msg</span><span class="sh">"</span><span class="p">]</span>
        <span class="n">error_details</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">loc</span><span class="si">}</span><span class="s">: </span><span class="si">{</span><span class="n">msg</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
    <span class="k">return</span> <span class="nc">JSONResponse</span><span class="p">(</span>
        <span class="n">status_code</span><span class="o">=</span><span class="mi">400</span><span class="p">,</span>
        <span class="n">content</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">success</span><span class="sh">"</span><span class="p">:</span> <span class="bp">False</span><span class="p">,</span> <span class="sh">"</span><span class="s">error</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">VALIDATION_ERROR</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">details</span><span class="sh">"</span><span class="p">:</span> <span class="n">error_details</span><span class="p">},</span>
    <span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">exc.errors()</code> returns a list of Pydantic error dicts. Each dict has a <code class="language-plaintext highlighter-rouge">loc</code> tuple (field path) and a <code class="language-plaintext highlighter-rouge">msg</code> string. The handler flattens the location into a readable <code class="language-plaintext highlighter-rouge">"body -&gt; target_url: value is not a valid URL"</code> string and collects all errors into the <code class="language-plaintext highlighter-rouge">details</code> list — so a single response surfaces every validation failure at once, not just the first one.</p>

<h3 id="handler-2-domain-exceptions-4xx">Handler 2: Domain exceptions (4xx)</h3>

<p>Domain exceptions are pure Python classes with zero FastAPI imports. They carry the HTTP status code and error payload, but they know nothing about <code class="language-plaintext highlighter-rouge">JSONResponse</code> or routing. The full set of exceptions lives in <a href="https://github.com/sasadangelo/fasturl/blob/main/src/fasturl/core/exceptions.py">exceptions.py</a>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">AppError</span><span class="p">(</span><span class="nb">Exception</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">message</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">status_code</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">400</span><span class="p">,</span> <span class="n">details</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="nf">super</span><span class="p">().</span><span class="nf">__init__</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
        <span class="n">self</span><span class="p">.</span><span class="n">message</span> <span class="o">=</span> <span class="n">message</span>
        <span class="n">self</span><span class="p">.</span><span class="n">status_code</span> <span class="o">=</span> <span class="n">status_code</span>
        <span class="n">self</span><span class="p">.</span><span class="n">details</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="n">details</span> <span class="ow">or</span> <span class="p">[]</span>

<span class="k">class</span> <span class="nc">LinkNotFoundError</span><span class="p">(</span><span class="n">AppError</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">code</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="nf">super</span><span class="p">().</span><span class="nf">__init__</span><span class="p">(</span>
            <span class="n">message</span><span class="o">=</span><span class="sh">"</span><span class="s">LINK_NOT_FOUND</span><span class="sh">"</span><span class="p">,</span>
            <span class="n">status_code</span><span class="o">=</span><span class="mi">404</span><span class="p">,</span>
            <span class="n">details</span><span class="o">=</span><span class="p">[</span><span class="sa">f</span><span class="sh">"</span><span class="s">code -&gt; No link found with code </span><span class="sh">'</span><span class="si">{</span><span class="n">code</span><span class="si">}</span><span class="sh">'"</span><span class="p">],</span>
        <span class="p">)</span>

<span class="k">class</span> <span class="nc">LinkExpiredError</span><span class="p">(</span><span class="n">AppError</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">code</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">expired_at</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="nf">super</span><span class="p">().</span><span class="nf">__init__</span><span class="p">(</span>
            <span class="n">message</span><span class="o">=</span><span class="sh">"</span><span class="s">LINK_EXPIRED</span><span class="sh">"</span><span class="p">,</span>
            <span class="n">status_code</span><span class="o">=</span><span class="mi">410</span><span class="p">,</span>
            <span class="n">details</span><span class="o">=</span><span class="p">[</span><span class="sa">f</span><span class="sh">"</span><span class="s">code -&gt; Link </span><span class="sh">'</span><span class="si">{</span><span class="n">code</span><span class="si">}</span><span class="sh">'</span><span class="s"> expired on </span><span class="si">{</span><span class="n">expired_at</span><span class="si">}</span><span class="sh">"</span><span class="p">],</span>
        <span class="p">)</span>
</code></pre></div></div>

<p>The global handler converts any <code class="language-plaintext highlighter-rouge">AppError</code> subclass into the standard envelope:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@app.exception_handler</span><span class="p">(</span><span class="n">AppError</span><span class="p">)</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">app_error_handler</span><span class="p">(</span><span class="n">request</span><span class="p">:</span> <span class="n">Request</span><span class="p">,</span> <span class="n">exc</span><span class="p">:</span> <span class="n">AppError</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">JSONResponse</span><span class="p">:</span>
    <span class="k">return</span> <span class="nc">JSONResponse</span><span class="p">(</span>
        <span class="n">status_code</span><span class="o">=</span><span class="n">exc</span><span class="p">.</span><span class="n">status_code</span><span class="p">,</span>
        <span class="n">content</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">success</span><span class="sh">"</span><span class="p">:</span> <span class="bp">False</span><span class="p">,</span> <span class="sh">"</span><span class="s">error</span><span class="sh">"</span><span class="p">:</span> <span class="n">exc</span><span class="p">.</span><span class="n">message</span><span class="p">,</span> <span class="sh">"</span><span class="s">details</span><span class="sh">"</span><span class="p">:</span> <span class="n">exc</span><span class="p">.</span><span class="n">details</span><span class="p">},</span>
    <span class="p">)</span>
</code></pre></div></div>

<p>The service layer raises <code class="language-plaintext highlighter-rouge">LinkNotFoundError</code>, <code class="language-plaintext highlighter-rouge">AliasAlreadyTakenError</code>, or <code class="language-plaintext highlighter-rouge">LinkExpiredError</code> without ever importing FastAPI. The HTTP layer sees the exception, the handler serialises it, the client gets a consistent envelope. The layers stay clean.</p>

<h3 id="handler-3-unhandled-exceptions-500">Handler 3: Unhandled exceptions (500)</h3>

<p>The third handler is the safety net. Any exception that escapes the service and repository layers — a database connection drop, a bug, an unexpected <code class="language-plaintext highlighter-rouge">None</code> — reaches this handler. It logs the full traceback server-side and returns a generic message to the client:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@app.exception_handler</span><span class="p">(</span><span class="nb">Exception</span><span class="p">)</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">global_exception_handler</span><span class="p">(</span><span class="n">request</span><span class="p">:</span> <span class="n">Request</span><span class="p">,</span> <span class="n">exc</span><span class="p">:</span> <span class="nb">Exception</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">JSONResponse</span><span class="p">:</span>
    <span class="n">tb</span> <span class="o">=</span> <span class="sh">""</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">traceback</span><span class="p">.</span><span class="nf">format_exception</span><span class="p">(</span><span class="nf">type</span><span class="p">(</span><span class="n">exc</span><span class="p">),</span> <span class="n">exc</span><span class="p">,</span> <span class="n">exc</span><span class="p">.</span><span class="n">__traceback__</span><span class="p">))</span>
    <span class="n">logger</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Unhandled exception at </span><span class="si">{</span><span class="n">request</span><span class="p">.</span><span class="n">url</span><span class="si">}</span><span class="s">:</span><span class="se">\n</span><span class="si">{</span><span class="n">tb</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
    <span class="k">return</span> <span class="nc">JSONResponse</span><span class="p">(</span>
        <span class="n">status_code</span><span class="o">=</span><span class="mi">500</span><span class="p">,</span>
        <span class="n">content</span><span class="o">=</span><span class="p">{</span>
            <span class="sh">"</span><span class="s">success</span><span class="sh">"</span><span class="p">:</span> <span class="bp">False</span><span class="p">,</span>
            <span class="sh">"</span><span class="s">error</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">INTERNAL_SERVER_ERROR</span><span class="sh">"</span><span class="p">,</span>
            <span class="sh">"</span><span class="s">details</span><span class="sh">"</span><span class="p">:</span> <span class="p">[</span><span class="sh">"</span><span class="s">An unexpected error occurred. Please contact support.</span><span class="sh">"</span><span class="p">],</span>
        <span class="p">},</span>
    <span class="p">)</span>
</code></pre></div></div>

<p>The traceback goes to the server log — never to the client. Leaking stack traces to API consumers is an information disclosure vulnerability: it reveals internal class names, file paths, and sometimes configuration values. The client sees only the generic message and the correlation ID header (<code class="language-plaintext highlighter-rouge">X-Request-ID</code>) that makes the server log entry findable.</p>

<h3 id="the-complete-handler-mapping">The complete handler mapping</h3>

<table>
  <thead>
    <tr>
      <th>Handler</th>
      <th>Triggered by</th>
      <th>HTTP status</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">RequestValidationError</code></td>
      <td>Pydantic schema validation failure</td>
      <td>400</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">AppError</code></td>
      <td>Domain rule violation</td>
      <td>404 / 409 / 410</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Exception</code></td>
      <td>Any unhandled error</td>
      <td>500</td>
    </tr>
  </tbody>
</table>

<p>The full registration of all three handlers is in <a href="https://github.com/sasadangelo/fasturl/blob/main/src/fasturl/core/exceptions.py">exceptions.py</a>.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this article we covered:</p>

<ul>
  <li>Why every I/O-bound route handler must be <code class="language-plaintext highlighter-rouge">async def</code> and why mixing <code class="language-plaintext highlighter-rouge">async def</code> with blocking libraries stalls the entire event loop.</li>
  <li>How to create a single shared <code class="language-plaintext highlighter-rouge">httpx.AsyncClient</code> in the FastAPI <code class="language-plaintext highlighter-rouge">lifespan</code> context and inject it via a dependency — and why creating a client per request destroys connection pooling.</li>
  <li>How the same principle applies to the SQLAlchemy <code class="language-plaintext highlighter-rouge">AsyncEngine</code>: one engine per application, opened in <code class="language-plaintext highlighter-rouge">lifespan</code>, disposed on shutdown — with a fresh <code class="language-plaintext highlighter-rouge">AsyncSession</code> per request opened by the <code class="language-plaintext highlighter-rouge">get_db</code> dependency.</li>
  <li>How <code class="language-plaintext highlighter-rouge">BackgroundTasks</code> runs <code class="language-plaintext highlighter-rouge">inspect_link</code> after the <code class="language-plaintext highlighter-rouge">201 Created</code> response is sent, keeping the HTTP cycle fast while the background work happens asynchronously in the same process.</li>
  <li>When <code class="language-plaintext highlighter-rouge">BackgroundTasks</code> is the right tool and when an external broker is necessary — durability, retry policies, and scale are the deciding factors.</li>
  <li>How three global exception handlers produce a single <code class="language-plaintext highlighter-rouge">{success, error, details}</code> envelope for Pydantic validation failures, domain exceptions, and unhandled errors — with no stack traces leaking to clients.</li>
</ul>

<p>The next article in the <a href="/how-to-set-up-your-next-python-project/">Modern Python Application</a> series will go deeper into testing patterns for Python applications — pytest fixtures, async tests, and coverage strategies for real production code.</p>]]></content><author><name>Salvatore D&apos;Angelo</name></author><category term="Programming" /><category term="programming" /><category term="python" /><summary type="html"><![CDATA[Async I/O, fire-and-forget background tasks, a shared httpx client, and three global exception handlers in FastAPI — grounded in the FastURL project.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://code4projects.org/assets/img/fastapi-async-background-tasks-and-error-handling-hero.svg" /><media:content medium="image" url="https://code4projects.org/assets/img/fastapi-async-background-tasks-and-error-handling-hero.svg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Layered Architecture with FastAPI: Routers, Services, and Repositories</title><link href="https://code4projects.org/layered-architecture-with-fastapi-routers-services-and-repositories/" rel="alternate" type="text/html" title="Layered Architecture with FastAPI: Routers, Services, and Repositories" /><published>2026-10-04T00:00:00+00:00</published><updated>2026-10-04T00:00:00+00:00</updated><id>https://code4projects.org/layered-architecture-with-fastapi-routers-services-and-repositories</id><content type="html" xml:base="https://code4projects.org/layered-architecture-with-fastapi-routers-services-and-repositories/"><![CDATA[<p><img src="/assets/img/layered-architecture-with-fastapi-routers-services-and-repositories-hero.png" alt="Layered Architecture with FastAPI: Routers, Services, and Repositories" width="760" height="400" class="responsive_img" /></p>

<h2 id="introduction">Introduction</h2>

<p>In the <a href="/from-domain-design-to-rest-api-building-fasturl-with-fastapi/">previous article</a> we defined the domain model and API contract for <strong>FastURL</strong>, our open-source URL shortener and link inspector service hosted on <a href="https://github.com/sasadangelo/fasturl">GitHub</a>. We established how requirements turn into domain entities, how Pydantic v2 schemas act as Value Objects, and how a consistent error envelope isolates clients from internal failure mechanics.</p>

<p>Designing endpoints and validation rules is essential, but code needs a clean home to live in. If you put database queries, HTTP handling, business validation, and external network requests into a single route function, your application will quickly turn into an unmaintainable monolith. We previously explored structuring a <a href="/database-layer-with-sqlalchemy-in-a-layered-python-architecture/">database layer with SQLAlchemy</a> and managing <a href="/async-and-event-loop-in-python-asyncio-in-practice/">asyncio event loop concurrency</a>; now we bring those principles together in an API context.</p>

<p>This article shows how to organize a real-world <a href="https://fastapi.tiangolo.com/">FastAPI</a> application into clean, isolated layers: <strong>Routers</strong>, <strong>Services</strong>, and <strong>Repositories</strong>, connected together seamlessly through FastAPI’s <strong>Dependency Injection</strong> system.</p>

<p>You should read this article if:</p>

<ul>
  <li>You want to structure a production-grade FastAPI codebase beyond trivial single-file tutorials.</li>
  <li>You want to decouple business logic from HTTP frameworks and database ORMs.</li>
  <li>You want to master async SQLAlchemy 2.0 repositories and FastAPI dependency injection (<code class="language-plaintext highlighter-rouge">Depends</code>).</li>
</ul>

<h2 id="the-problem-with-fat-route-handlers">The Problem with Fat Route Handlers</h2>

<p>Imagine an endpoint that receives a URL shortening request, checks database duplicates, executes an insert, sends a background notification, and formats the JSON response all within the same 50-line route function.</p>

<p>While this works for simple scripts, it fails in production:</p>

<ol>
  <li><strong>No reusability</strong>: If a background worker or CLI needs to shorten a URL, it cannot reuse the route logic without importing HTTP request objects.</li>
  <li><strong>Untestable logic</strong>: Unit testing business rules requires spinning up mock HTTP clients and a live database instance.</li>
  <li><strong>Leaky abstractions</strong>: The HTTP layer is tightly coupled to database schema details, table column names, and ORM session states.</li>
</ol>

<p>To prevent this decay, we structure FastURL using a classic <strong>Layered Architecture</strong>.</p>

<p><img src="/assets/img/layered-architecture-with-fastapi-routers-services-and-repositories-overview.png" alt="FastURL layered architecture: router, service, and repository responsibilities" width="760" height="400" class="responsive_img" /></p>

<blockquote>
  <p>The dependency arrow points strictly downward: Routers depend on Services, and Services depend on Repositories. Lower layers never know about the layers above them.</p>
</blockquote>

<h2 id="real-project-folder-structure">Real Project Folder Structure</h2>

<p>Here is how the <a href="https://github.com/sasadangelo/fasturl">FastURL codebase</a> is organized inside <code class="language-plaintext highlighter-rouge">src/fasturl/</code>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fasturl/
├── config.yaml                # Application configuration file
├── pyproject.toml             # Dependencies and build configuration (uv / PEP 621)
├── sql/
│   └── schema.sql             # Reference SQL schema definition
├── src/
│   └── fasturl/
│       ├── api/               # Router layer: endpoints, schemas, dependencies
│       │   ├── dependencies/  # FastAPI dependency providers (Depends factories)
│       │   ├── middleware/    # HTTP middleware (CORS, error handling, etc.)
│       │   ├── routers/       # APIRouter definitions (links, redirect)
│       │   └── schemas/       # Pydantic request and response DTO schemas
│       ├── core/              # Cross-cutting: configuration, logging, database, exceptions
│       │   ├── config/        # Pydantic Settings modules (app, db, log, inspector)
│       │   ├── database.py    # Async SQLAlchemy engine &amp; session factory
│       │   ├── exceptions.py  # Domain error hierarchy (AppError, ValidationError, etc.)
│       │   └── log.py         # Loguru structured logging configuration
│       ├── models/            # SQLAlchemy ORM models (table mappings)
│       │   └── link.py
│       ├── repositories/      # Data access layer (async repositories)
│       │   └── link_repository.py
│       ├── services/          # Business logic layer (LinkService, InspectorService)
│       │   ├── inspector_service.py
│       │   └── link_service.py
│       └── main.py            # FastAPI application factory, lifespan, router registration
└── tests/
    ├── unit/                  # Fast isolated unit tests (mocked repositories)
    └── integration/           # End-to-end HTTP tests with TestClient and SQLite
</code></pre></div></div>

<p>Each module has a clearly delineated responsibility:</p>

<ul>
  <li><strong><a href="https://github.com/sasadangelo/fasturl/tree/main/src/fasturl/models"><code class="language-plaintext highlighter-rouge">models/</code></a></strong>: Houses SQLAlchemy declarative ORM mappings.</li>
  <li><strong><a href="https://github.com/sasadangelo/fasturl/tree/main/src/fasturl/repositories"><code class="language-plaintext highlighter-rouge">repositories/</code></a></strong>: Contains classes that handle raw SQL operations and query execution using <a href="https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html"><code class="language-plaintext highlighter-rouge">AsyncSession</code></a>.</li>
  <li><strong><a href="https://github.com/sasadangelo/fasturl/tree/main/src/fasturl/services"><code class="language-plaintext highlighter-rouge">services/</code></a></strong>: Encapsulates pure domain workflows and business rules. Free of any HTTP framework dependencies.</li>
  <li><strong><a href="https://github.com/sasadangelo/fasturl/tree/main/src/fasturl/api"><code class="language-plaintext highlighter-rouge">api/</code></a></strong>: Contains FastAPI routers, input/output schemas (DTOs), and dependency injection wiring.</li>
  <li><strong><a href="https://github.com/sasadangelo/fasturl/tree/main/src/fasturl/core"><code class="language-plaintext highlighter-rouge">core/</code></a></strong>: Provides infrastructure foundations:
    <ul>
      <li><strong><code class="language-plaintext highlighter-rouge">config/</code></strong>: Type-safe settings with <a href="/managing-application-configuration-in-python-with-pydantic-settings/">Pydantic Settings</a> across application, database, and logging domains.</li>
      <li><strong><code class="language-plaintext highlighter-rouge">database.py</code></strong>: Async SQLAlchemy session lifecycle and engine initialization, expanding on our <a href="/database-layer-with-sqlalchemy-in-a-layered-python-architecture/">SQLAlchemy database layer</a>.</li>
      <li><strong><code class="language-plaintext highlighter-rouge">log.py</code></strong>: Structured application logging with <a href="/logging-in-a-flask-application-with-loguru/">Loguru</a>.</li>
      <li><strong><code class="language-plaintext highlighter-rouge">exceptions.py</code></strong>: Clean domain error hierarchy (<code class="language-plaintext highlighter-rouge">AppError</code>, <code class="language-plaintext highlighter-rouge">ValidationError</code>, <code class="language-plaintext highlighter-rouge">LinkNotFoundError</code>).</li>
    </ul>
  </li>
</ul>

<h2 id="domain-model-mapping-embedding-value-objects-in-the-orm">Domain Model Mapping: Embedding Value Objects in the ORM</h2>

<p>In FastURL’s domain design, the <code class="language-plaintext highlighter-rouge">Link</code> entity is the Aggregate Root. Its Value Objects — <code class="language-plaintext highlighter-rouge">ShortCode</code>, <code class="language-plaintext highlighter-rouge">TargetUrl</code>, <code class="language-plaintext highlighter-rouge">LinkInspection</code>, and <code class="language-plaintext highlighter-rouge">LinkMetrics</code> — do not have separate lifecycle identities.</p>

<p>In the database, we map this domain structure to a clean single-table schema (<code class="language-plaintext highlighter-rouge">links</code>), embedding the Value Object properties as flat columns.</p>

<p>Here is the SQLAlchemy 2.0 ORM model in <a href="https://github.com/sasadangelo/fasturl/blob/main/src/fasturl/models/link.py"><code class="language-plaintext highlighter-rouge">src/fasturl/models/link.py</code></a>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">datetime</span> <span class="kn">import</span> <span class="n">UTC</span><span class="p">,</span> <span class="n">datetime</span>
<span class="kn">from</span> <span class="n">sqlalchemy</span> <span class="kn">import</span> <span class="n">Boolean</span><span class="p">,</span> <span class="n">DateTime</span><span class="p">,</span> <span class="n">Float</span><span class="p">,</span> <span class="n">Integer</span><span class="p">,</span> <span class="n">String</span><span class="p">,</span> <span class="n">Text</span>
<span class="kn">from</span> <span class="n">sqlalchemy.orm</span> <span class="kn">import</span> <span class="n">DeclarativeBase</span><span class="p">,</span> <span class="n">Mapped</span><span class="p">,</span> <span class="n">mapped_column</span>

<span class="k">class</span> <span class="nc">Base</span><span class="p">(</span><span class="n">DeclarativeBase</span><span class="p">):</span>
    <span class="sh">"""</span><span class="s">Shared declarative base for all ORM models.</span><span class="sh">"""</span>

<span class="k">class</span> <span class="nc">Link</span><span class="p">(</span><span class="n">Base</span><span class="p">):</span>
    <span class="n">__tablename__</span> <span class="o">=</span> <span class="sh">"</span><span class="s">links</span><span class="sh">"</span>

    <span class="nb">id</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="nb">int</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="n">Integer</span><span class="p">,</span> <span class="n">primary_key</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">autoincrement</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">code</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="nc">String</span><span class="p">(</span><span class="n">length</span><span class="o">=</span><span class="mi">16</span><span class="p">),</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span> <span class="n">unique</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">index</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">target_url</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="n">Text</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">is_active</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="nb">bool</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="n">Boolean</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">expires_at</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="n">datetime</span> <span class="o">|</span> <span class="bp">None</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="n">DateTime</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="bp">None</span><span class="p">)</span>

    <span class="c1"># LinkInspection Value Object — embedded columns
</span>    <span class="n">inspection_status</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="nc">String</span><span class="p">(</span><span class="n">length</span><span class="o">=</span><span class="mi">20</span><span class="p">),</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="sh">"</span><span class="s">pending_analysis</span><span class="sh">"</span><span class="p">)</span>
    <span class="n">http_status_code</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="nb">int</span> <span class="o">|</span> <span class="bp">None</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="n">Integer</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="bp">None</span><span class="p">)</span>
    <span class="n">latency_ms</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="nb">float</span> <span class="o">|</span> <span class="bp">None</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="n">Float</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="bp">None</span><span class="p">)</span>
    <span class="n">title</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="n">Text</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="bp">None</span><span class="p">)</span>
    <span class="n">description</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="n">Text</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="bp">None</span><span class="p">)</span>
    <span class="n">image_url</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="n">Text</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="bp">None</span><span class="p">)</span>
    <span class="n">last_checked_at</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="n">datetime</span> <span class="o">|</span> <span class="bp">None</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="n">DateTime</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="bp">None</span><span class="p">)</span>

    <span class="c1"># LinkMetrics Value Object — embedded columns
</span>    <span class="n">clicks_count</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="nb">int</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="n">Integer</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span>
    <span class="n">last_clicked_at</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="n">datetime</span> <span class="o">|</span> <span class="bp">None</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span><span class="n">DateTime</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="bp">None</span><span class="p">)</span>

    <span class="c1"># Audit timestamps
</span>    <span class="n">created_at</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="n">datetime</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span>
        <span class="n">DateTime</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="k">lambda</span><span class="p">:</span> <span class="n">datetime</span><span class="p">.</span><span class="nf">now</span><span class="p">(</span><span class="n">UTC</span><span class="p">).</span><span class="nf">replace</span><span class="p">(</span><span class="n">tzinfo</span><span class="o">=</span><span class="bp">None</span><span class="p">)</span>
    <span class="p">)</span>
    <span class="n">updated_at</span><span class="p">:</span> <span class="n">Mapped</span><span class="p">[</span><span class="n">datetime</span><span class="p">]</span> <span class="o">=</span> <span class="nf">mapped_column</span><span class="p">(</span>
        <span class="n">DateTime</span><span class="p">,</span>
        <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span>
        <span class="n">default</span><span class="o">=</span><span class="k">lambda</span><span class="p">:</span> <span class="n">datetime</span><span class="p">.</span><span class="nf">now</span><span class="p">(</span><span class="n">UTC</span><span class="p">).</span><span class="nf">replace</span><span class="p">(</span><span class="n">tzinfo</span><span class="o">=</span><span class="bp">None</span><span class="p">),</span>
        <span class="n">onupdate</span><span class="o">=</span><span class="k">lambda</span><span class="p">:</span> <span class="n">datetime</span><span class="p">.</span><span class="nf">now</span><span class="p">(</span><span class="n">UTC</span><span class="p">).</span><span class="nf">replace</span><span class="p">(</span><span class="n">tzinfo</span><span class="o">=</span><span class="bp">None</span><span class="p">),</span>
    <span class="p">)</span>
</code></pre></div></div>

<p>Notice a key design decision: the surrogate primary key <code class="language-plaintext highlighter-rouge">id</code> is internal only. The sole public identifier is <code class="language-plaintext highlighter-rouge">code</code> (the 7–16 character Base62 string), which is indexed and unique.</p>

<h2 id="the-repository-layer-async-data-access">The Repository Layer: Async Data Access</h2>

<p>The Repository (<code class="language-plaintext highlighter-rouge">LinkRepository</code> in <a href="https://github.com/sasadangelo/fasturl/blob/main/src/fasturl/repositories/link_repository.py"><code class="language-plaintext highlighter-rouge">src/fasturl/repositories/link_repository.py</code></a>) has a single job: encapsulate SQL operations using SQLAlchemy’s async engine. It contains no business logic, no status code definitions, and no pricing rules.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">datetime</span> <span class="kn">import</span> <span class="n">UTC</span><span class="p">,</span> <span class="n">datetime</span>
<span class="kn">from</span> <span class="n">sqlalchemy</span> <span class="kn">import</span> <span class="n">func</span><span class="p">,</span> <span class="n">select</span><span class="p">,</span> <span class="n">update</span>
<span class="kn">from</span> <span class="n">sqlalchemy.engine.result</span> <span class="kn">import</span> <span class="n">Result</span>
<span class="kn">from</span> <span class="n">sqlalchemy.ext.asyncio</span> <span class="kn">import</span> <span class="n">AsyncSession</span>
<span class="kn">from</span> <span class="n">fasturl.models.link</span> <span class="kn">import</span> <span class="n">Link</span>

<span class="k">class</span> <span class="nc">LinkRepository</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">session</span><span class="p">:</span> <span class="n">AsyncSession</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">self</span><span class="p">.</span><span class="n">_session</span> <span class="o">=</span> <span class="n">session</span>

    <span class="k">async</span> <span class="k">def</span> <span class="nf">get_by_code</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">code</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Link</span> <span class="o">|</span> <span class="bp">None</span><span class="p">:</span>
        <span class="sh">"""</span><span class="s">Return the Link with the given code, or None if not found.</span><span class="sh">"""</span>
        <span class="n">result</span><span class="p">:</span> <span class="n">Result</span><span class="p">[</span><span class="n">Link</span><span class="p">]</span> <span class="o">=</span> <span class="k">await</span> <span class="n">self</span><span class="p">.</span><span class="n">_session</span><span class="p">.</span><span class="nf">execute</span><span class="p">(</span>
            <span class="nf">select</span><span class="p">(</span><span class="n">Link</span><span class="p">).</span><span class="nf">where</span><span class="p">(</span><span class="n">Link</span><span class="p">.</span><span class="n">code</span> <span class="o">==</span> <span class="n">code</span><span class="p">)</span>
        <span class="p">)</span>
        <span class="k">return</span> <span class="n">result</span><span class="p">.</span><span class="nf">scalar_one_or_none</span><span class="p">()</span>

    <span class="k">async</span> <span class="k">def</span> <span class="nf">create</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">link</span><span class="p">:</span> <span class="n">Link</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Link</span><span class="p">:</span>
        <span class="sh">"""</span><span class="s">Persist a new Link record and return the refreshed instance.</span><span class="sh">"""</span>
        <span class="n">self</span><span class="p">.</span><span class="n">_session</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="n">instance</span><span class="o">=</span><span class="n">link</span><span class="p">)</span>
        <span class="k">await</span> <span class="n">self</span><span class="p">.</span><span class="n">_session</span><span class="p">.</span><span class="nf">commit</span><span class="p">()</span>
        <span class="k">await</span> <span class="n">self</span><span class="p">.</span><span class="n">_session</span><span class="p">.</span><span class="nf">refresh</span><span class="p">(</span><span class="n">instance</span><span class="o">=</span><span class="n">link</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">link</span>

    <span class="k">async</span> <span class="k">def</span> <span class="nf">soft_delete</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">code</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
        <span class="sh">"""</span><span class="s">Set is_active = False for the link with the given code.</span><span class="sh">"""</span>
        <span class="n">result</span><span class="p">:</span> <span class="n">Result</span><span class="p">[</span><span class="nb">int</span><span class="p">]</span> <span class="o">=</span> <span class="k">await</span> <span class="n">self</span><span class="p">.</span><span class="n">_session</span><span class="p">.</span><span class="nf">execute</span><span class="p">(</span>
            <span class="nf">update</span><span class="p">(</span><span class="n">table</span><span class="o">=</span><span class="n">Link</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="n">Link</span><span class="p">.</span><span class="n">code</span> <span class="o">==</span> <span class="n">code</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">values</span><span class="p">(</span><span class="n">is_active</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span> <span class="n">updated_at</span><span class="o">=</span><span class="n">datetime</span><span class="p">.</span><span class="nf">now</span><span class="p">(</span><span class="n">UTC</span><span class="p">).</span><span class="nf">replace</span><span class="p">(</span><span class="n">tzinfo</span><span class="o">=</span><span class="bp">None</span><span class="p">))</span>
            <span class="p">.</span><span class="nf">returning</span><span class="p">(</span><span class="n">Link</span><span class="p">.</span><span class="nb">id</span><span class="p">)</span>
        <span class="p">)</span>
        <span class="k">await</span> <span class="n">self</span><span class="p">.</span><span class="n">_session</span><span class="p">.</span><span class="nf">commit</span><span class="p">()</span>
        <span class="k">return</span> <span class="n">result</span><span class="p">.</span><span class="nf">scalar_one_or_none</span><span class="p">()</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span>

    <span class="k">async</span> <span class="k">def</span> <span class="nf">increment_clicks</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">code</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="sh">"""</span><span class="s">Atomically increment clicks_count and update last_clicked_at.</span><span class="sh">"""</span>
        <span class="k">await</span> <span class="n">self</span><span class="p">.</span><span class="n">_session</span><span class="p">.</span><span class="nf">execute</span><span class="p">(</span>
            <span class="nf">update</span><span class="p">(</span><span class="n">Link</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="n">Link</span><span class="p">.</span><span class="n">code</span> <span class="o">==</span> <span class="n">code</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">values</span><span class="p">(</span>
                <span class="n">clicks_count</span><span class="o">=</span><span class="n">Link</span><span class="p">.</span><span class="n">clicks_count</span> <span class="o">+</span> <span class="mi">1</span><span class="p">,</span>
                <span class="n">last_clicked_at</span><span class="o">=</span><span class="n">func</span><span class="p">.</span><span class="nf">now</span><span class="p">(),</span>
                <span class="n">updated_at</span><span class="o">=</span><span class="n">func</span><span class="p">.</span><span class="nf">now</span><span class="p">(),</span>
            <span class="p">)</span>
        <span class="p">)</span>
        <span class="k">await</span> <span class="n">self</span><span class="p">.</span><span class="n">_session</span><span class="p">.</span><span class="nf">commit</span><span class="p">()</span>

    <span class="k">async</span> <span class="k">def</span> <span class="nf">code_exists</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">code</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
        <span class="sh">"""</span><span class="s">Return True if any link uses the given code.</span><span class="sh">"""</span>
        <span class="n">result</span><span class="p">:</span> <span class="n">Result</span><span class="p">[</span><span class="nb">int</span><span class="p">]</span> <span class="o">=</span> <span class="k">await</span> <span class="n">self</span><span class="p">.</span><span class="n">_session</span><span class="p">.</span><span class="nf">execute</span><span class="p">(</span>
            <span class="nf">select</span><span class="p">(</span><span class="n">Link</span><span class="p">.</span><span class="nb">id</span><span class="p">).</span><span class="nf">where</span><span class="p">(</span><span class="n">Link</span><span class="p">.</span><span class="n">code</span> <span class="o">==</span> <span class="n">code</span><span class="p">)</span>
        <span class="p">)</span>
        <span class="k">return</span> <span class="n">result</span><span class="p">.</span><span class="nf">scalar_one_or_none</span><span class="p">()</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span>
</code></pre></div></div>

<p>All methods are fully asynchronous and use type-safe SQLAlchemy 2.0 select and update constructs. Notice that updates like <code class="language-plaintext highlighter-rouge">increment_clicks</code> use database-level atomic increments (<code class="language-plaintext highlighter-rouge">clicks_count = Link.clicks_count + 1</code>) to eliminate race conditions under concurrent traffic.</p>

<h2 id="the-service-layer-pure-business-logic">The Service Layer: Pure Business Logic</h2>

<p>The Service layer (<code class="language-plaintext highlighter-rouge">LinkService</code> in <a href="https://github.com/sasadangelo/fasturl/blob/main/src/fasturl/services/link_service.py"><code class="language-plaintext highlighter-rouge">src/fasturl/services/link_service.py</code></a>) is the brain of the application. It orchestrates business workflows, generates random Base62 codes, handles code collision retries, and raises domain exceptions.</p>

<p>Crucially, <strong><code class="language-plaintext highlighter-rouge">LinkService</code> has zero knowledge of FastAPI or HTTP</strong>. It does not import <code class="language-plaintext highlighter-rouge">Request</code>, <code class="language-plaintext highlighter-rouge">Response</code>, or <code class="language-plaintext highlighter-rouge">HTTPException</code>.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">random</span>
<span class="kn">import</span> <span class="n">string</span>
<span class="kn">from</span> <span class="n">datetime</span> <span class="kn">import</span> <span class="n">UTC</span><span class="p">,</span> <span class="n">datetime</span>
<span class="kn">from</span> <span class="n">loguru</span> <span class="kn">import</span> <span class="n">logger</span>
<span class="kn">from</span> <span class="n">fasturl.api.schemas.link_schemas</span> <span class="kn">import</span> <span class="n">LinkResponse</span><span class="p">,</span> <span class="n">InspectionResponse</span><span class="p">,</span> <span class="n">MetricsResponse</span>
<span class="kn">from</span> <span class="n">fasturl.core.config</span> <span class="kn">import</span> <span class="n">settings</span>
<span class="kn">from</span> <span class="n">fasturl.core.exceptions</span> <span class="kn">import</span> <span class="n">AliasAlreadyTakenError</span><span class="p">,</span> <span class="n">LinkExpiredError</span><span class="p">,</span> <span class="n">LinkNotFoundError</span><span class="p">,</span> <span class="n">ValidationError</span>
<span class="kn">from</span> <span class="n">fasturl.models.link</span> <span class="kn">import</span> <span class="n">Link</span>
<span class="kn">from</span> <span class="n">fasturl.repositories.link_repository</span> <span class="kn">import</span> <span class="n">LinkRepository</span>

<span class="n">_log</span> <span class="o">=</span> <span class="n">logger</span><span class="p">.</span><span class="nf">bind</span><span class="p">(</span><span class="n">name</span><span class="o">=</span><span class="sh">"</span><span class="s">LinkService</span><span class="sh">"</span><span class="p">)</span>
<span class="n">_BASE62_ALPHABET</span> <span class="o">=</span> <span class="n">string</span><span class="p">.</span><span class="n">ascii_letters</span> <span class="o">+</span> <span class="n">string</span><span class="p">.</span><span class="n">digits</span>
<span class="n">_MAX_CODE_RETRIES</span> <span class="o">=</span> <span class="mi">10</span>

<span class="k">class</span> <span class="nc">LinkService</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">repository</span><span class="p">:</span> <span class="n">LinkRepository</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">self</span><span class="p">.</span><span class="n">_repository</span> <span class="o">=</span> <span class="n">repository</span>

    <span class="k">def</span> <span class="nf">_generate_code</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
        <span class="sh">"""</span><span class="s">Generate a random Base62 code of the configured length.</span><span class="sh">"""</span>
        <span class="n">length</span> <span class="o">=</span> <span class="n">settings</span><span class="p">.</span><span class="n">app</span><span class="p">.</span><span class="n">code_length</span>
        <span class="k">return</span> <span class="sh">""</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">random</span><span class="p">.</span><span class="nf">choices</span><span class="p">(</span><span class="n">_BASE62_ALPHABET</span><span class="p">,</span> <span class="n">k</span><span class="o">=</span><span class="n">length</span><span class="p">))</span>

    <span class="k">def</span> <span class="nf">_validate_target_url</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">target_url</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="sh">"""</span><span class="s">Enforce self-redirect loop prevention.</span><span class="sh">"""</span>
        <span class="n">base</span> <span class="o">=</span> <span class="n">settings</span><span class="p">.</span><span class="n">app</span><span class="p">.</span><span class="n">base_url</span><span class="p">.</span><span class="nf">rstrip</span><span class="p">(</span><span class="sh">"</span><span class="s">/</span><span class="sh">"</span><span class="p">).</span><span class="nf">lower</span><span class="p">()</span>
        <span class="k">if</span> <span class="n">target_url</span><span class="p">.</span><span class="nf">lower</span><span class="p">().</span><span class="nf">startswith</span><span class="p">(</span><span class="n">base</span><span class="p">):</span>
            <span class="k">raise</span> <span class="nc">ValidationError</span><span class="p">([</span><span class="sh">"</span><span class="s">target_url -&gt; URL must not point back to this service (self-redirect)</span><span class="sh">"</span><span class="p">])</span>

    <span class="k">async</span> <span class="k">def</span> <span class="nf">create_link</span><span class="p">(</span>
        <span class="n">self</span><span class="p">,</span>
        <span class="n">target_url</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
        <span class="n">custom_code</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span><span class="p">,</span>
        <span class="n">expires_at</span><span class="p">:</span> <span class="n">datetime</span> <span class="o">|</span> <span class="bp">None</span><span class="p">,</span>
    <span class="p">)</span> <span class="o">-&gt;</span> <span class="n">LinkResponse</span><span class="p">:</span>
        <span class="sh">"""</span><span class="s">Create a new shortened link, checking collisions and invariants.</span><span class="sh">"""</span>
        <span class="n">self</span><span class="p">.</span><span class="nf">_validate_target_url</span><span class="p">(</span><span class="n">target_url</span><span class="p">)</span>

        <span class="k">if</span> <span class="n">custom_code</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
            <span class="k">if</span> <span class="k">await</span> <span class="n">self</span><span class="p">.</span><span class="n">_repository</span><span class="p">.</span><span class="nf">code_exists</span><span class="p">(</span><span class="n">code</span><span class="o">=</span><span class="n">custom_code</span><span class="p">):</span>
                <span class="k">raise</span> <span class="nc">AliasAlreadyTakenError</span><span class="p">(</span><span class="n">alias</span><span class="o">=</span><span class="n">custom_code</span><span class="p">)</span>
            <span class="n">code</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="n">custom_code</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="c1"># Collision-retry loop for auto-generated codes
</span>            <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="n">_MAX_CODE_RETRIES</span><span class="p">):</span>
                <span class="n">candidate</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">_generate_code</span><span class="p">()</span>
                <span class="k">if</span> <span class="ow">not</span> <span class="k">await</span> <span class="n">self</span><span class="p">.</span><span class="n">_repository</span><span class="p">.</span><span class="nf">code_exists</span><span class="p">(</span><span class="n">code</span><span class="o">=</span><span class="n">candidate</span><span class="p">):</span>
                    <span class="n">code</span> <span class="o">=</span> <span class="n">candidate</span>
                    <span class="k">break</span>
            <span class="k">else</span><span class="p">:</span>
                <span class="n">_log</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="sh">"</span><span class="s">Failed to generate a unique code after max retries</span><span class="sh">"</span><span class="p">)</span>
                <span class="k">raise</span> <span class="nc">ValidationError</span><span class="p">([</span><span class="sh">"</span><span class="s">code -&gt; Could not generate a unique short code. Please try again.</span><span class="sh">"</span><span class="p">])</span>

        <span class="n">now</span><span class="p">:</span> <span class="n">datetime</span> <span class="o">=</span> <span class="n">datetime</span><span class="p">.</span><span class="nf">now</span><span class="p">(</span><span class="n">UTC</span><span class="p">).</span><span class="nf">replace</span><span class="p">(</span><span class="n">tzinfo</span><span class="o">=</span><span class="bp">None</span><span class="p">)</span>
        <span class="n">link</span> <span class="o">=</span> <span class="nc">Link</span><span class="p">(</span>
            <span class="n">code</span><span class="o">=</span><span class="n">code</span><span class="p">,</span>
            <span class="n">target_url</span><span class="o">=</span><span class="n">target_url</span><span class="p">,</span>
            <span class="n">is_active</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
            <span class="n">expires_at</span><span class="o">=</span><span class="n">expires_at</span><span class="p">,</span>
            <span class="n">inspection_status</span><span class="o">=</span><span class="sh">"</span><span class="s">pending_analysis</span><span class="sh">"</span><span class="p">,</span>
            <span class="n">clicks_count</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span>
            <span class="n">created_at</span><span class="o">=</span><span class="n">now</span><span class="p">,</span>
            <span class="n">updated_at</span><span class="o">=</span><span class="n">now</span><span class="p">,</span>
        <span class="p">)</span>
        <span class="n">persisted</span> <span class="o">=</span> <span class="k">await</span> <span class="n">self</span><span class="p">.</span><span class="n">_repository</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span><span class="n">link</span><span class="p">)</span>
        <span class="n">_log</span><span class="p">.</span><span class="nf">info</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Link created: code=</span><span class="sh">'</span><span class="si">{</span><span class="n">code</span><span class="si">}</span><span class="sh">'</span><span class="s">, target=</span><span class="sh">'</span><span class="si">{</span><span class="n">target_url</span><span class="si">}</span><span class="sh">'"</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">self</span><span class="p">.</span><span class="nf">_orm_to_response</span><span class="p">(</span><span class="n">persisted</span><span class="p">)</span>

    <span class="k">async</span> <span class="k">def</span> <span class="nf">resolve_for_redirect</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">code</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
        <span class="sh">"""</span><span class="s">Resolve a short code for redirect, validating active status and expiry.</span><span class="sh">"""</span>
        <span class="n">link</span> <span class="o">=</span> <span class="k">await</span> <span class="n">self</span><span class="p">.</span><span class="n">_repository</span><span class="p">.</span><span class="nf">get_by_code</span><span class="p">(</span><span class="n">code</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">link</span> <span class="ow">is</span> <span class="bp">None</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">link</span><span class="p">.</span><span class="n">is_active</span><span class="p">:</span>
            <span class="k">raise</span> <span class="nc">LinkNotFoundError</span><span class="p">(</span><span class="n">code</span><span class="p">)</span>

        <span class="k">if</span> <span class="n">link</span><span class="p">.</span><span class="n">expires_at</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
            <span class="n">expires</span> <span class="o">=</span> <span class="n">link</span><span class="p">.</span><span class="n">expires_at</span><span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="n">tzinfo</span><span class="o">=</span><span class="bp">None</span><span class="p">)</span> <span class="k">if</span> <span class="n">link</span><span class="p">.</span><span class="n">expires_at</span><span class="p">.</span><span class="n">tzinfo</span> <span class="k">else</span> <span class="n">link</span><span class="p">.</span><span class="n">expires_at</span>
            <span class="k">if</span> <span class="n">datetime</span><span class="p">.</span><span class="nf">now</span><span class="p">(</span><span class="n">UTC</span><span class="p">).</span><span class="nf">replace</span><span class="p">(</span><span class="n">tzinfo</span><span class="o">=</span><span class="bp">None</span><span class="p">)</span> <span class="o">&gt;</span> <span class="n">expires</span><span class="p">:</span>
                <span class="k">raise</span> <span class="nc">LinkExpiredError</span><span class="p">(</span><span class="n">code</span><span class="p">,</span> <span class="n">expired_at</span><span class="o">=</span><span class="n">link</span><span class="p">.</span><span class="n">expires_at</span><span class="p">.</span><span class="nf">isoformat</span><span class="p">())</span>

        <span class="k">return</span> <span class="n">link</span><span class="p">.</span><span class="n">target_url</span>

    <span class="k">def</span> <span class="nf">_orm_to_response</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">link</span><span class="p">:</span> <span class="n">Link</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">LinkResponse</span><span class="p">:</span>
        <span class="n">base</span> <span class="o">=</span> <span class="n">settings</span><span class="p">.</span><span class="n">app</span><span class="p">.</span><span class="n">base_url</span><span class="p">.</span><span class="nf">rstrip</span><span class="p">(</span><span class="sh">"</span><span class="s">/</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">return</span> <span class="nc">LinkResponse</span><span class="p">(</span>
            <span class="n">code</span><span class="o">=</span><span class="n">link</span><span class="p">.</span><span class="n">code</span><span class="p">,</span>
            <span class="n">short_url</span><span class="o">=</span><span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">base</span><span class="si">}</span><span class="s">/</span><span class="si">{</span><span class="n">link</span><span class="p">.</span><span class="n">code</span><span class="si">}</span><span class="sh">"</span><span class="p">,</span>
            <span class="n">target_url</span><span class="o">=</span><span class="n">link</span><span class="p">.</span><span class="n">target_url</span><span class="p">,</span>
            <span class="n">is_active</span><span class="o">=</span><span class="n">link</span><span class="p">.</span><span class="n">is_active</span><span class="p">,</span>
            <span class="n">expires_at</span><span class="o">=</span><span class="n">link</span><span class="p">.</span><span class="n">expires_at</span><span class="p">,</span>
            <span class="n">inspection</span><span class="o">=</span><span class="nc">InspectionResponse</span><span class="p">(</span>
                <span class="n">status</span><span class="o">=</span><span class="n">link</span><span class="p">.</span><span class="n">inspection_status</span><span class="p">,</span>
                <span class="n">http_status_code</span><span class="o">=</span><span class="n">link</span><span class="p">.</span><span class="n">http_status_code</span><span class="p">,</span>
                <span class="n">latency_ms</span><span class="o">=</span><span class="n">link</span><span class="p">.</span><span class="n">latency_ms</span><span class="p">,</span>
                <span class="n">title</span><span class="o">=</span><span class="n">link</span><span class="p">.</span><span class="n">title</span><span class="p">,</span>
                <span class="n">description</span><span class="o">=</span><span class="n">link</span><span class="p">.</span><span class="n">description</span><span class="p">,</span>
                <span class="n">image_url</span><span class="o">=</span><span class="n">link</span><span class="p">.</span><span class="n">image_url</span><span class="p">,</span>
                <span class="n">last_checked_at</span><span class="o">=</span><span class="n">link</span><span class="p">.</span><span class="n">last_checked_at</span><span class="p">,</span>
            <span class="p">),</span>
            <span class="n">metrics</span><span class="o">=</span><span class="nc">MetricsResponse</span><span class="p">(</span>
                <span class="n">clicks_count</span><span class="o">=</span><span class="n">link</span><span class="p">.</span><span class="n">clicks_count</span><span class="p">,</span>
                <span class="n">last_clicked_at</span><span class="o">=</span><span class="n">link</span><span class="p">.</span><span class="n">last_clicked_at</span><span class="p">,</span>
            <span class="p">),</span>
            <span class="n">created_at</span><span class="o">=</span><span class="n">link</span><span class="p">.</span><span class="n">created_at</span><span class="p">,</span>
            <span class="n">updated_at</span><span class="o">=</span><span class="n">link</span><span class="p">.</span><span class="n">updated_at</span><span class="p">,</span>
        <span class="p">)</span>
</code></pre></div></div>

<p>Because <code class="language-plaintext highlighter-rouge">LinkService</code> takes <code class="language-plaintext highlighter-rouge">LinkRepository</code> in its constructor, testing business logic becomes trivial. In unit tests, you can inject a mock repository without needing a running database or HTTP server.</p>

<h2 id="the-router-layer-thin-handlers-and-http-contracts">The Router Layer: Thin Handlers and HTTP Contracts</h2>

<p>The Router layer (<a href="https://github.com/sasadangelo/fasturl/blob/main/src/fasturl/api/routers/links.py"><code class="language-plaintext highlighter-rouge">src/fasturl/api/routers/links.py</code></a> and <a href="https://github.com/sasadangelo/fasturl/blob/main/src/fasturl/api/routers/redirect.py"><code class="language-plaintext highlighter-rouge">redirect.py</code></a>) is responsible strictly for HTTP concerns: routing paths, status codes, query/body parameter parsing, and dispatching background tasks.</p>

<h3 id="1-management-endpoints-apiv1links">1. Management Endpoints (<code class="language-plaintext highlighter-rouge">/api/v1/links</code>)</h3>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">typing</span> <span class="kn">import</span> <span class="n">Annotated</span><span class="p">,</span> <span class="n">TypeAlias</span>
<span class="kn">from</span> <span class="n">fastapi</span> <span class="kn">import</span> <span class="n">APIRouter</span><span class="p">,</span> <span class="n">BackgroundTasks</span><span class="p">,</span> <span class="n">Path</span><span class="p">,</span> <span class="n">status</span>
<span class="kn">from</span> <span class="n">fasturl.api.dependencies</span> <span class="kn">import</span> <span class="n">HTTPClientDep</span><span class="p">,</span> <span class="n">LinkServiceDep</span>
<span class="kn">from</span> <span class="n">fasturl.api.schemas.link_schemas</span> <span class="kn">import</span> <span class="n">LinkCreateRequest</span><span class="p">,</span> <span class="n">LinkResponse</span>
<span class="kn">from</span> <span class="n">fasturl.services.inspector_service</span> <span class="kn">import</span> <span class="n">inspect_link</span>

<span class="n">router</span> <span class="o">=</span> <span class="nc">APIRouter</span><span class="p">(</span><span class="n">prefix</span><span class="o">=</span><span class="sh">"</span><span class="s">/api/v1/links</span><span class="sh">"</span><span class="p">,</span> <span class="n">tags</span><span class="o">=</span><span class="p">[</span><span class="sh">"</span><span class="s">Links</span><span class="sh">"</span><span class="p">])</span>

<span class="n">_CodePath</span><span class="p">:</span> <span class="n">TypeAlias</span> <span class="o">=</span> <span class="n">Annotated</span><span class="p">[</span>
    <span class="nb">str</span><span class="p">,</span>
    <span class="nc">Path</span><span class="p">(</span><span class="n">min_length</span><span class="o">=</span><span class="mi">7</span><span class="p">,</span> <span class="n">max_length</span><span class="o">=</span><span class="mi">16</span><span class="p">,</span> <span class="n">description</span><span class="o">=</span><span class="sh">"</span><span class="s">Base62 short code or alias</span><span class="sh">"</span><span class="p">),</span>
<span class="p">]</span>

<span class="nd">@router.post</span><span class="p">(</span>
    <span class="n">path</span><span class="o">=</span><span class="sh">""</span><span class="p">,</span>
    <span class="n">response_model</span><span class="o">=</span><span class="n">LinkResponse</span><span class="p">,</span>
    <span class="n">status_code</span><span class="o">=</span><span class="n">status</span><span class="p">.</span><span class="n">HTTP_201_CREATED</span><span class="p">,</span>
    <span class="n">summary</span><span class="o">=</span><span class="sh">"</span><span class="s">Create a new shortened link</span><span class="sh">"</span><span class="p">,</span>
<span class="p">)</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">create_link</span><span class="p">(</span>
    <span class="n">body</span><span class="p">:</span> <span class="n">LinkCreateRequest</span><span class="p">,</span>
    <span class="n">service</span><span class="p">:</span> <span class="n">LinkServiceDep</span><span class="p">,</span>
    <span class="n">http_client</span><span class="p">:</span> <span class="n">HTTPClientDep</span><span class="p">,</span>
    <span class="n">background_tasks</span><span class="p">:</span> <span class="n">BackgroundTasks</span><span class="p">,</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="n">LinkResponse</span><span class="p">:</span>
    <span class="sh">"""</span><span class="s">Create link and dispatch non-blocking background inspection.</span><span class="sh">"""</span>
    <span class="n">response</span> <span class="o">=</span> <span class="k">await</span> <span class="n">service</span><span class="p">.</span><span class="nf">create_link</span><span class="p">(</span>
        <span class="n">target_url</span><span class="o">=</span><span class="nf">str</span><span class="p">(</span><span class="n">body</span><span class="p">.</span><span class="n">target_url</span><span class="p">),</span>
        <span class="n">custom_code</span><span class="o">=</span><span class="n">body</span><span class="p">.</span><span class="n">custom_code</span><span class="p">,</span>
        <span class="n">expires_at</span><span class="o">=</span><span class="n">body</span><span class="p">.</span><span class="n">expires_at</span><span class="p">,</span>
    <span class="p">)</span>
    <span class="n">background_tasks</span><span class="p">.</span><span class="nf">add_task</span><span class="p">(</span>
        <span class="n">func</span><span class="o">=</span><span class="n">inspect_link</span><span class="p">,</span>
        <span class="n">code</span><span class="o">=</span><span class="n">response</span><span class="p">.</span><span class="n">code</span><span class="p">,</span>
        <span class="n">target_url</span><span class="o">=</span><span class="n">response</span><span class="p">.</span><span class="n">target_url</span><span class="p">,</span>
        <span class="n">http_client</span><span class="o">=</span><span class="n">http_client</span><span class="p">,</span>
        <span class="n">repository</span><span class="o">=</span><span class="n">service</span><span class="p">.</span><span class="n">_repository</span><span class="p">,</span>
    <span class="p">)</span>
    <span class="k">return</span> <span class="n">response</span>

<span class="nd">@router.get</span><span class="p">(</span>
    <span class="n">path</span><span class="o">=</span><span class="sh">"</span><span class="s">/{code}</span><span class="sh">"</span><span class="p">,</span>
    <span class="n">response_model</span><span class="o">=</span><span class="n">LinkResponse</span><span class="p">,</span>
    <span class="n">status_code</span><span class="o">=</span><span class="n">status</span><span class="p">.</span><span class="n">HTTP_200_OK</span><span class="p">,</span>
    <span class="n">summary</span><span class="o">=</span><span class="sh">"</span><span class="s">Retrieve single link details</span><span class="sh">"</span><span class="p">,</span>
<span class="p">)</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">get_link</span><span class="p">(</span><span class="n">code</span><span class="p">:</span> <span class="n">_CodePath</span><span class="p">,</span> <span class="n">service</span><span class="p">:</span> <span class="n">LinkServiceDep</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">LinkResponse</span><span class="p">:</span>
    <span class="k">return</span> <span class="k">await</span> <span class="n">service</span><span class="p">.</span><span class="nf">get_link</span><span class="p">(</span><span class="n">code</span><span class="p">)</span>
</code></pre></div></div>

<h3 id="2-public-redirect-endpoint-code">2. Public Redirect Endpoint (<code class="language-plaintext highlighter-rouge">/{code}</code>)</h3>

<p>The redirect endpoint in <a href="https://github.com/sasadangelo/fasturl/blob/main/src/fasturl/api/routers/redirect.py"><code class="language-plaintext highlighter-rouge">src/fasturl/api/routers/redirect.py</code></a> lives at the root URL (without <code class="language-plaintext highlighter-rouge">/api/v1</code>):</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">fastapi</span> <span class="kn">import</span> <span class="n">APIRouter</span><span class="p">,</span> <span class="n">BackgroundTasks</span>
<span class="kn">from</span> <span class="n">fastapi.responses</span> <span class="kn">import</span> <span class="n">RedirectResponse</span>
<span class="kn">from</span> <span class="n">fasturl.api.dependencies</span> <span class="kn">import</span> <span class="n">LinkServiceDep</span>

<span class="n">router</span> <span class="o">=</span> <span class="nc">APIRouter</span><span class="p">(</span><span class="n">tags</span><span class="o">=</span><span class="p">[</span><span class="sh">"</span><span class="s">Redirect</span><span class="sh">"</span><span class="p">])</span>

<span class="nd">@router.get</span><span class="p">(</span>
    <span class="n">path</span><span class="o">=</span><span class="sh">"</span><span class="s">/{code}</span><span class="sh">"</span><span class="p">,</span>
    <span class="n">status_code</span><span class="o">=</span><span class="mi">307</span><span class="p">,</span>
    <span class="n">response_class</span><span class="o">=</span><span class="n">RedirectResponse</span><span class="p">,</span>
    <span class="n">summary</span><span class="o">=</span><span class="sh">"</span><span class="s">Resolve and redirect short URL</span><span class="sh">"</span><span class="p">,</span>
<span class="p">)</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">redirect_short_url</span><span class="p">(</span>
    <span class="n">code</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
    <span class="n">service</span><span class="p">:</span> <span class="n">LinkServiceDep</span><span class="p">,</span>
    <span class="n">background_tasks</span><span class="p">:</span> <span class="n">BackgroundTasks</span><span class="p">,</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="n">RedirectResponse</span><span class="p">:</span>
    <span class="sh">"""</span><span class="s">Resolve short code and issue 307 redirect; record clicks in background.</span><span class="sh">"""</span>
    <span class="n">target_url</span> <span class="o">=</span> <span class="k">await</span> <span class="n">service</span><span class="p">.</span><span class="nf">resolve_for_redirect</span><span class="p">(</span><span class="n">code</span><span class="p">)</span>

    <span class="c1"># Click counter increment happens non-blockingly after response is returned
</span>    <span class="n">background_tasks</span><span class="p">.</span><span class="nf">add_task</span><span class="p">(</span>
        <span class="n">func</span><span class="o">=</span><span class="n">service</span><span class="p">.</span><span class="n">_repository</span><span class="p">.</span><span class="n">increment_clicks</span><span class="p">,</span>
        <span class="n">code</span><span class="o">=</span><span class="n">code</span><span class="p">,</span>
    <span class="p">)</span>

    <span class="k">return</span> <span class="nc">RedirectResponse</span><span class="p">(</span><span class="n">url</span><span class="o">=</span><span class="n">target_url</span><span class="p">,</span> <span class="n">status_code</span><span class="o">=</span><span class="mi">307</span><span class="p">)</span>
</code></pre></div></div>

<p>Notice that <code class="language-plaintext highlighter-rouge">307 Temporary Redirect</code> is chosen instead of <code class="language-plaintext highlighter-rouge">301 Moved Permanently</code>. A <code class="language-plaintext highlighter-rouge">301</code> redirect is aggressively cached by web browsers, which would prevent the server from tracking subsequent clicks for the same user.</p>

<h2 id="deep-dive-dependency-injection-in-fastapi">Deep Dive: Dependency Injection in FastAPI</h2>

<h3 id="what-is-dependency-injection">What Is Dependency Injection?</h3>

<p>Imagine you are assembling a desktop computer. If the CPU was permanently soldered to the power supply and hardwired to a specific monitor, replacing the monitor or testing the CPU in isolation would be impossible. Instead, standard sockets and cables decouple each component: the power supply plugs in where electricity is needed, and the monitor plugs into the video port.</p>

<p>In software, <strong>Dependency Injection (DI)</strong> is that exact plug-and-socket design.</p>

<p>Without Dependency Injection, a class or function creates its own dependencies internally:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Anti-pattern: Hardcoded dependency creation (tight coupling)
</span><span class="k">class</span> <span class="nc">LinkService</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="c1"># LinkService is tightly coupled to AsyncSession and sqlite:///fasturl.db
</span>        <span class="n">self</span><span class="p">.</span><span class="n">session</span> <span class="o">=</span> <span class="nf">create_async_session</span><span class="p">(</span><span class="sh">"</span><span class="s">sqlite+aiosqlite:///instance/fasturl.db</span><span class="sh">"</span><span class="p">)</span>
        <span class="n">self</span><span class="p">.</span><span class="n">repository</span> <span class="o">=</span> <span class="nc">LinkRepository</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">session</span><span class="p">)</span>
</code></pre></div></div>

<p>With Dependency Injection, an object <strong>receives</strong> its dependencies from an outside provider rather than instantiating them itself:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Clean pattern: Dependency Injection via constructor
</span><span class="k">class</span> <span class="nc">LinkService</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">repository</span><span class="p">:</span> <span class="n">LinkRepository</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">self</span><span class="p">.</span><span class="n">_repository</span> <span class="o">=</span> <span class="n">repository</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">LinkService</code> no longer cares <em>how</em> <code class="language-plaintext highlighter-rouge">LinkRepository</code> is created, which database connection string was used, or whether it’s talking to PostgreSQL, SQLite, or an in-memory mock during tests.</p>

<h3 id="why-is-dependency-injection-essential">Why Is Dependency Injection Essential?</h3>

<p>Dependency Injection solves four critical software architecture challenges:</p>

<ol>
  <li><strong>Unit Testing without Database or Network Overheads</strong>:
When writing tests for <code class="language-plaintext highlighter-rouge">LinkService</code>, you do not want to start a database engine, run migrations, or clean up test records. You can instantiate <code class="language-plaintext highlighter-rouge">LinkService(MockLinkRepository())</code> directly and verify domain logic in milliseconds.</li>
  <li><strong>Deterministic Resource Lifecycles (Session Scoping)</strong>:
Database sessions, connection pools, and HTTP clients must be opened before a request starts and safely closed (or rolled back) when the request finishes. DI automates this teardown per request without polluting business functions with try-finally blocks.</li>
  <li><strong>Inversion of Control (IoC)</strong>:
High-level modules (business services) do not depend on low-level implementation details (SQLAlchemy engines). Both depend on abstractions.</li>
  <li><strong>Declarative and Type-Safe Route Signatures</strong>:
With Python type hints and FastAPI <code class="language-plaintext highlighter-rouge">Annotated</code>, handlers state what they need in their signature, and the framework resolves the dependency graph.</li>
</ol>

<h3 id="how-fastapi-resolves-the-dependency-graph-with-depends">How FastAPI Resolves the Dependency Graph with <code class="language-plaintext highlighter-rouge">Depends</code></h3>

<p>FastAPI provides a first-class DI system via <a href="https://fastapi.tiangolo.com/tutorial/dependencies/"><code class="language-plaintext highlighter-rouge">Depends</code></a>. In FastURL, the entire dependency graph is wired in <a href="https://github.com/sasadangelo/fasturl/blob/main/src/fasturl/api/dependencies/__init__.py"><code class="language-plaintext highlighter-rouge">src/fasturl/api/dependencies/__init__.py</code></a>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">typing</span> <span class="kn">import</span> <span class="n">Annotated</span><span class="p">,</span> <span class="n">TypeAlias</span>
<span class="kn">import</span> <span class="n">httpx</span>
<span class="kn">from</span> <span class="n">fastapi</span> <span class="kn">import</span> <span class="n">Depends</span><span class="p">,</span> <span class="n">Request</span>
<span class="kn">from</span> <span class="n">sqlalchemy.ext.asyncio</span> <span class="kn">import</span> <span class="n">AsyncSession</span>
<span class="kn">from</span> <span class="n">fasturl.core.database</span> <span class="kn">import</span> <span class="n">get_db</span>
<span class="kn">from</span> <span class="n">fasturl.repositories.link_repository</span> <span class="kn">import</span> <span class="n">LinkRepository</span>
<span class="kn">from</span> <span class="n">fasturl.services.link_service</span> <span class="kn">import</span> <span class="n">LinkService</span>

<span class="c1"># 1. Yields an AsyncSession per request and guarantees cleanup
</span><span class="n">DBSessionDep</span><span class="p">:</span> <span class="n">TypeAlias</span> <span class="o">=</span> <span class="n">Annotated</span><span class="p">[</span><span class="n">AsyncSession</span><span class="p">,</span> <span class="nc">Depends</span><span class="p">(</span><span class="n">dependency</span><span class="o">=</span><span class="n">get_db</span><span class="p">)]</span>

<span class="c1"># 2. Reuses the shared httpx.AsyncClient stored in FastAPI app.state during lifespan
</span><span class="k">def</span> <span class="nf">_get_http_client</span><span class="p">(</span><span class="n">request</span><span class="p">:</span> <span class="n">Request</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">httpx</span><span class="p">.</span><span class="n">AsyncClient</span><span class="p">:</span>
    <span class="k">return</span> <span class="n">request</span><span class="p">.</span><span class="n">app</span><span class="p">.</span><span class="n">state</span><span class="p">.</span><span class="n">http_client</span>

<span class="n">HTTPClientDep</span><span class="p">:</span> <span class="n">TypeAlias</span> <span class="o">=</span> <span class="n">Annotated</span><span class="p">[</span><span class="n">httpx</span><span class="p">.</span><span class="n">AsyncClient</span><span class="p">,</span> <span class="nc">Depends</span><span class="p">(</span><span class="n">dependency</span><span class="o">=</span><span class="n">_get_http_client</span><span class="p">)]</span>

<span class="c1"># 3. Builds a LinkRepository scoped to the active database session
</span><span class="k">def</span> <span class="nf">_get_link_repository</span><span class="p">(</span><span class="n">session</span><span class="p">:</span> <span class="n">DBSessionDep</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">LinkRepository</span><span class="p">:</span>
    <span class="k">return</span> <span class="nc">LinkRepository</span><span class="p">(</span><span class="n">session</span><span class="p">)</span>

<span class="n">LinkRepoDep</span><span class="p">:</span> <span class="n">TypeAlias</span> <span class="o">=</span> <span class="n">Annotated</span><span class="p">[</span><span class="n">LinkRepository</span><span class="p">,</span> <span class="nc">Depends</span><span class="p">(</span><span class="n">dependency</span><span class="o">=</span><span class="n">_get_link_repository</span><span class="p">)]</span>

<span class="c1"># 4. Builds LinkService injected with LinkRepository
</span><span class="k">def</span> <span class="nf">_get_link_service</span><span class="p">(</span><span class="n">repository</span><span class="p">:</span> <span class="n">LinkRepoDep</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">LinkService</span><span class="p">:</span>
    <span class="k">return</span> <span class="nc">LinkService</span><span class="p">(</span><span class="n">repository</span><span class="o">=</span><span class="n">repository</span><span class="p">)</span>

<span class="n">LinkServiceDep</span><span class="p">:</span> <span class="n">TypeAlias</span> <span class="o">=</span> <span class="n">Annotated</span><span class="p">[</span><span class="n">LinkService</span><span class="p">,</span> <span class="nc">Depends</span><span class="p">(</span><span class="n">dependency</span><span class="o">=</span><span class="n">_get_link_service</span><span class="p">)]</span>
</code></pre></div></div>

<h3 id="the-request-execution-flow">The Request Execution Flow</h3>

<p>When an HTTP request reaches <code class="language-plaintext highlighter-rouge">POST /api/v1/links</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@router.post</span><span class="p">(</span><span class="sh">""</span><span class="p">)</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">create_link</span><span class="p">(</span>
    <span class="n">body</span><span class="p">:</span> <span class="n">LinkCreateRequest</span><span class="p">,</span>
    <span class="n">service</span><span class="p">:</span> <span class="n">LinkServiceDep</span><span class="p">,</span>
    <span class="n">http_client</span><span class="p">:</span> <span class="n">HTTPClientDep</span><span class="p">,</span>
    <span class="n">background_tasks</span><span class="p">:</span> <span class="n">BackgroundTasks</span><span class="p">,</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="n">LinkResponse</span><span class="p">:</span>
    <span class="bp">...</span>
</code></pre></div></div>

<p>FastAPI automatically traverses and resolves the dependency hierarchy:</p>

<p><img src="/assets/img/layered-architecture-with-fastapi-routers-services-and-repositories-dependency-injection-flow.png" alt="FastAPI dependency injection flow from request resolution to session cleanup" width="760" height="550" class="responsive_img" /></p>

<h3 id="overriding-dependencies-in-integration-tests">Overriding Dependencies in Integration Tests</h3>

<p>One of the greatest benefits of FastAPI’s DI engine is <code class="language-plaintext highlighter-rouge">app.dependency_overrides</code>. In integration tests, you can replace any provider in the chain without changing application code:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">fastapi.testclient</span> <span class="kn">import</span> <span class="n">TestClient</span>
<span class="kn">from</span> <span class="n">fasturl.api.dependencies</span> <span class="kn">import</span> <span class="n">_get_link_service</span>
<span class="kn">from</span> <span class="n">fasturl.main</span> <span class="kn">import</span> <span class="n">app</span>

<span class="k">def</span> <span class="nf">test_create_link_with_mocked_service</span><span class="p">():</span>
    <span class="c1"># Override LinkService with a mock or test instance
</span>    <span class="n">app</span><span class="p">.</span><span class="n">dependency_overrides</span><span class="p">[</span><span class="n">_get_link_service</span><span class="p">]</span> <span class="o">=</span> <span class="k">lambda</span><span class="p">:</span> <span class="nc">FakeLinkService</span><span class="p">()</span>

    <span class="n">client</span> <span class="o">=</span> <span class="nc">TestClient</span><span class="p">(</span><span class="n">app</span><span class="p">)</span>
    <span class="n">response</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="nf">post</span><span class="p">(</span><span class="sh">"</span><span class="s">/api/v1/links</span><span class="sh">"</span><span class="p">,</span> <span class="n">json</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">target_url</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">https://example.com</span><span class="sh">"</span><span class="p">})</span>

    <span class="k">assert</span> <span class="n">response</span><span class="p">.</span><span class="n">status_code</span> <span class="o">==</span> <span class="mi">201</span>
    
    <span class="c1"># Clean up overrides after test
</span>    <span class="n">app</span><span class="p">.</span><span class="n">dependency_overrides</span><span class="p">.</span><span class="nf">clear</span><span class="p">()</span>
</code></pre></div></div>

<h2 id="architectural-summary">Architectural Summary</h2>

<p>By enforcing strict boundaries across layers, each part of FastURL has a singular focus:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Router</th>
      <th>Service</th>
      <th>Repository</th>
      <th>Model</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Module Path</strong></td>
      <td><code class="language-plaintext highlighter-rouge">fasturl.api.routers</code></td>
      <td><code class="language-plaintext highlighter-rouge">fasturl.services</code></td>
      <td><code class="language-plaintext highlighter-rouge">fasturl.repositories</code></td>
      <td><code class="language-plaintext highlighter-rouge">fasturl.models</code></td>
    </tr>
    <tr>
      <td><strong>Primary Responsibility</strong></td>
      <td>HTTP request parsing, status codes, background tasks</td>
      <td>Domain workflows, invariants, collision resolution</td>
      <td>Database reads and writes, SQL queries, atomic updates</td>
      <td>Declarative table mappings, database indexes</td>
    </tr>
    <tr>
      <td><strong>Depends On</strong></td>
      <td>Services (<code class="language-plaintext highlighter-rouge">fasturl.services</code>), Schemas</td>
      <td>Repositories (<code class="language-plaintext highlighter-rouge">fasturl.repositories</code>), Models</td>
      <td>Database Sessions (<code class="language-plaintext highlighter-rouge">AsyncSession</code>), Models</td>
      <td>None (pure declarative schema)</td>
    </tr>
    <tr>
      <td><strong>What It Must NOT Know</strong></td>
      <td>Database queries, SQL ORM details</td>
      <td>FastAPI routers, HTTP request and response objects</td>
      <td>HTTP headers, validation schemas, routing</td>
      <td>HTTP endpoints, application business logic</td>
    </tr>
  </tbody>
</table>

<h2 id="conclusion">Conclusion</h2>

<p>In this article we covered:</p>

<ul>
  <li><strong>The Layered Architecture pattern</strong>: how organizing code into Routers, Services, and Repositories prevents codebase decay and creates clean separation of concerns.</li>
  <li><strong>The FastURL project structure</strong>: how <code class="language-plaintext highlighter-rouge">api/</code>, <code class="language-plaintext highlighter-rouge">services/</code>, <code class="language-plaintext highlighter-rouge">repositories/</code>, <code class="language-plaintext highlighter-rouge">models/</code>, and <code class="language-plaintext highlighter-rouge">core/</code> packages separate responsibilities cleanly.</li>
  <li><strong>Domain model to ORM mapping</strong>: mapping the <code class="language-plaintext highlighter-rouge">Link</code> Aggregate Root and embedded Value Objects to flat SQLAlchemy 2.0 table columns.</li>
  <li><strong>Async Repository pattern</strong>: executing queries, atomic increments, and soft-deletions using <code class="language-plaintext highlighter-rouge">AsyncSession</code>.</li>
  <li><strong>Pure business services</strong>: generating Base62 short codes, verifying self-redirect invariants, and raising domain exceptions without HTTP coupling.</li>
  <li><strong>Dependency Injection</strong>: constructing automated per-request dependency graphs using FastAPI’s <code class="language-plaintext highlighter-rouge">Depends</code> and typed <code class="language-plaintext highlighter-rouge">Annotated</code> aliases.</li>
</ul>

<p>The <a href="/fastapi-async-background-tasks-and-error-handling/">next article</a> covers advanced operational features in FastAPI: async background tasks, managing shared HTTP connection pools with <code class="language-plaintext highlighter-rouge">httpx.AsyncClient</code> in lifespan context, and global exception handlers.</p>]]></content><author><name>Salvatore D&apos;Angelo</name></author><category term="Programming" /><category term="programming" /><category term="python" /><summary type="html"><![CDATA[Learn how to structure a production-ready FastAPI project using a layered architecture: Routers, Services, Repositories, and Dependency Injection.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://code4projects.org/assets/img/layered-architecture-with-fastapi-routers-services-and-repositories-hero.png" /><media:content medium="image" url="https://code4projects.org/assets/img/layered-architecture-with-fastapi-routers-services-and-repositories-hero.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">From Domain Design to REST API: Building FastURL with FastAPI</title><link href="https://code4projects.org/from-domain-design-to-rest-api-building-fasturl-with-fastapi/" rel="alternate" type="text/html" title="From Domain Design to REST API: Building FastURL with FastAPI" /><published>2026-10-03T00:00:00+00:00</published><updated>2026-10-03T00:00:00+00:00</updated><id>https://code4projects.org/from-domain-design-to-rest-api-building-fasturl-with-fastapi</id><content type="html" xml:base="https://code4projects.org/from-domain-design-to-rest-api-building-fasturl-with-fastapi/"><![CDATA[<p><img src="/assets/img/from-domain-design-to-rest-api-building-fasturl-with-fastapi-hero.png" alt="From Domain Design to REST API: Building FastURL with FastAPI" width="760" height="400" class="responsive_img" /></p>

<h2 id="introduction">Introduction</h2>

<p>In the <a href="/designing-rest-apis-in-practice-a-kubernetes-case-study/">previous article</a> we established the theory: requirements produce domain entities, domain entities become REST resources, and HTTP methods map to the lifecycle operations of those resources. Kubernetes was the illustration — large enough to make the pattern clear, but too complex to implement in a blog post.</p>

<p>This article moves from theory to practice. The project is <strong>FastURL</strong>, a URL shortener and link inspector built with <a href="https://fastapi.tiangolo.com/">FastAPI</a>. It is small enough to fit comfortably in a few articles, but real enough to demonstrate every concept that matters: domain modeling, Pydantic validation as a form of type-safe Value Objects, a standardized error envelope, and a resource hierarchy derived directly from the domain.</p>

<p>You should read this article if:</p>

<ul>
  <li>You have read the previous article and want to see the methodology applied to real code.</li>
  <li>You want to understand how Pydantic v2 validators enforce domain rules at the HTTP boundary.</li>
  <li>You want to see how a consistent error contract is designed and implemented from scratch.</li>
</ul>

<h2 id="what-fasturl-does">What FastURL Does</h2>

<p>FastURL is a REST API that shortens long URLs into compact 7-character codes and resolves them back via HTTP redirects. Beyond basic shortening, it runs an asynchronous background inspection on every newly created link: it fetches the target page, records the HTTP status code and latency, and extracts the HTML title and OpenGraph metadata.</p>

<p>Three actors interact with the system:</p>

<table>
  <thead>
    <tr>
      <th>Actor</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>API Client</strong></td>
      <td>Creates, lists, retrieves, and deletes shortened links</td>
    </tr>
    <tr>
      <td><strong>Public Visitor</strong></td>
      <td>Navigates to a short URL and gets redirected to the target</td>
    </tr>
    <tr>
      <td><strong>Background Inspector</strong></td>
      <td>Asynchronously checks target URL health after creation</td>
    </tr>
  </tbody>
</table>

<p>This actor breakdown comes directly from the <a href="https://github.com/sasadangelo/fasturl/blob/main/docs/requirements.md">requirements document</a> — before any code was written. The nouns in the job stories (<code class="language-plaintext highlighter-rouge">Link</code>, <code class="language-plaintext highlighter-rouge">ShortCode</code>, <code class="language-plaintext highlighter-rouge">TargetUrl</code>, <code class="language-plaintext highlighter-rouge">Inspection</code>, <code class="language-plaintext highlighter-rouge">Metrics</code>) became the domain entities. The verbs (<code class="language-plaintext highlighter-rouge">shorten</code>, <code class="language-plaintext highlighter-rouge">resolve</code>, <code class="language-plaintext highlighter-rouge">inspect</code>, <code class="language-plaintext highlighter-rouge">delete</code>) became the operations.</p>

<h2 id="from-requirements-to-domain-entities">From Requirements to Domain Entities</h2>

<p>Every requirement in FastURL can be traced to one of eight job stories documented in the <a href="https://github.com/sasadangelo/fasturl/blob/main/docs/requirements.md">requirements document</a>. Three of them are worth looking at closely because they drive the entire domain design.</p>

<p><strong>JS-001</strong> — <em>When I provide a valid target URL, I want a unique shortened URL with a random alphanumeric code.</em> This story introduces two domain concepts: a <code class="language-plaintext highlighter-rouge">Link</code> (the association between a short code and a target URL) and a <code class="language-plaintext highlighter-rouge">ShortCode</code> (a validated 7-character Base62 string). The story also establishes a business rule: the code must be unique and collision-resistant.</p>

<p><strong>JS-006</strong> — <em>When I navigate to a short URL, I want to be redirected immediately.</em> This story adds two more rules: the link must be active (<code class="language-plaintext highlighter-rouge">is_active = true</code>) and must not have passed its expiration date (<code class="language-plaintext highlighter-rouge">expires_at</code>). If either condition fails, the redirect must not happen.</p>

<p><strong>JS-007</strong> — <em>When a new link is registered, I want a non-blocking background check on the target URL.</em> This story introduces the <code class="language-plaintext highlighter-rouge">LinkInspection</code> concept: a snapshot of target URL health, latency, and page metadata. Crucially, it must not block the creation response — the system acknowledges the creation immediately and inspects asynchronously. This is the <a href="/async-and-event-loop-in-python-asyncio-in-practice/">async I/O pattern</a> we covered earlier in the series.</p>

<p>These three stories alone define the core of the domain model.</p>

<h3 id="the-domain-model">The Domain Model</h3>

<p>FastURL has a single Aggregate Root: <code class="language-plaintext highlighter-rouge">Link</code>. It owns all the data and enforces all invariants. The Value Objects — <code class="language-plaintext highlighter-rouge">ShortCode</code>, <code class="language-plaintext highlighter-rouge">TargetUrl</code>, <code class="language-plaintext highlighter-rouge">LinkMetrics</code>, and <code class="language-plaintext highlighter-rouge">LinkInspection</code> — are part of the <code class="language-plaintext highlighter-rouge">Link</code> aggregate, not separate entities with their own identity.</p>

<p><img src="/assets/img/fasturl-domain-model.png" alt="FastURL Domain Model Architecture" width="760" height="400" class="responsive_img" /></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Link (Aggregate Root)
├── code          ← ShortCode (Base62, 7–16 chars, unique)
├── target_url    ← TargetUrl (HTTP/HTTPS only, no self-redirect)
├── is_active     ← soft-delete flag
├── expires_at    ← optional expiry
├── inspection    ← LinkInspection (status, latency, title, og:*)
└── metrics       ← LinkMetrics (clicks_count, last_clicked_at)
</code></pre></div></div>

<p>There are two bounded contexts: <strong>LinkManagement</strong> (CRUD and redirect resolution) and <strong>LinkInspection</strong> (async health check and metadata extraction). They communicate via a <code class="language-plaintext highlighter-rouge">LinkCreated</code> event that triggers the background task.</p>

<blockquote>
  <p>A single aggregate with Value Objects as members keeps the domain model flat and readable. <code class="language-plaintext highlighter-rouge">LinkInspection</code> and <code class="language-plaintext highlighter-rouge">LinkMetrics</code> have no identity of their own — they only make sense as part of a <code class="language-plaintext highlighter-rouge">Link</code>.</p>
</blockquote>

<h3 id="mapping-entities-to-resources">Mapping Entities to Resources</h3>

<p>The mapping from domain to REST is direct:</p>

<table>
  <thead>
    <tr>
      <th>Domain Entity</th>
      <th>REST Resource</th>
      <th>Methods</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Link</code> collection</td>
      <td><code class="language-plaintext highlighter-rouge">/api/v1/links</code></td>
      <td><code class="language-plaintext highlighter-rouge">GET</code>, <code class="language-plaintext highlighter-rouge">POST</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Link</code> instance</td>
      <td><code class="language-plaintext highlighter-rouge">/api/v1/links/{code}</code></td>
      <td><code class="language-plaintext highlighter-rouge">GET</code>, <code class="language-plaintext highlighter-rouge">DELETE</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">LinkInspection</code> action</td>
      <td><code class="language-plaintext highlighter-rouge">/api/v1/links/{code}/inspect</code></td>
      <td><code class="language-plaintext highlighter-rouge">POST</code></td>
    </tr>
    <tr>
      <td><em>(public redirect)</em></td>
      <td><code class="language-plaintext highlighter-rouge">/{code}</code></td>
      <td><code class="language-plaintext highlighter-rouge">GET</code></td>
    </tr>
  </tbody>
</table>

<p>Notice that <code class="language-plaintext highlighter-rouge">/{code}</code> carries no <code class="language-plaintext highlighter-rouge">/api/v1/</code> prefix. This is intentional: it is a consumer-facing URL that must be short and stable, not a versioned management endpoint.</p>

<p>The <code class="language-plaintext highlighter-rouge">POST /api/v1/links/{code}/inspect</code> endpoint does not map to a CRUD operation — it triggers an action. As discussed in the previous article, <code class="language-plaintext highlighter-rouge">POST</code> is the correct method for non-CRUD actions on a resource.</p>

<h2 id="pydantic-as-value-object-implementation">Pydantic as Value Object Implementation</h2>

<p>In Domain-Driven Design, a Value Object encapsulates validation rules and equality semantics. In FastAPI, <a href="https://docs.pydantic.dev/latest/">Pydantic</a> models serve exactly this role at the HTTP boundary. The <code class="language-plaintext highlighter-rouge">LinkCreateRequest</code> schema enforces every business rule on inbound data before the service layer is ever called.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">LinkCreateRequest</span><span class="p">(</span><span class="n">BaseModel</span><span class="p">):</span>
    <span class="n">target_url</span><span class="p">:</span> <span class="n">HttpUrl</span> <span class="o">=</span> <span class="nc">Field</span><span class="p">(</span>
        <span class="n">description</span><span class="o">=</span><span class="sh">"</span><span class="s">Destination URL to shorten. Must be HTTP or HTTPS.</span><span class="sh">"</span><span class="p">,</span>
    <span class="p">)</span>
    <span class="n">custom_code</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="nc">Field</span><span class="p">(</span>
        <span class="n">default</span><span class="o">=</span><span class="bp">None</span><span class="p">,</span>
        <span class="n">min_length</span><span class="o">=</span><span class="mi">7</span><span class="p">,</span>
        <span class="n">max_length</span><span class="o">=</span><span class="mi">16</span><span class="p">,</span>
    <span class="p">)</span>
    <span class="n">expires_at</span><span class="p">:</span> <span class="n">datetime</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="nc">Field</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="bp">None</span><span class="p">)</span>

    <span class="nd">@field_validator</span><span class="p">(</span><span class="sh">"</span><span class="s">custom_code</span><span class="sh">"</span><span class="p">)</span>
    <span class="nd">@classmethod</span>
    <span class="k">def</span> <span class="nf">validate_custom_code</span><span class="p">(</span><span class="n">cls</span><span class="p">,</span> <span class="n">v</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">v</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span> <span class="ow">and</span> <span class="ow">not</span> <span class="n">_BASE62_PATTERN</span><span class="p">.</span><span class="nf">match</span><span class="p">(</span><span class="n">v</span><span class="p">):</span>
            <span class="k">raise</span> <span class="nc">ValueError</span><span class="p">(</span><span class="sh">"</span><span class="s">custom_code must contain only alphanumeric characters [a-zA-Z0-9]</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">v</span>

    <span class="nd">@model_validator</span><span class="p">(</span><span class="n">mode</span><span class="o">=</span><span class="sh">"</span><span class="s">after</span><span class="sh">"</span><span class="p">)</span>
    <span class="k">def</span> <span class="nf">validate_expires_at_future</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">LinkCreateRequest</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">self</span><span class="p">.</span><span class="n">expires_at</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
            <span class="n">now</span> <span class="o">=</span> <span class="n">datetime</span><span class="p">.</span><span class="nf">now</span><span class="p">(</span><span class="n">UTC</span><span class="p">).</span><span class="nf">replace</span><span class="p">(</span><span class="n">tzinfo</span><span class="o">=</span><span class="bp">None</span><span class="p">)</span>
            <span class="n">expires</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">expires_at</span><span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="n">tzinfo</span><span class="o">=</span><span class="bp">None</span><span class="p">)</span>
            <span class="k">if</span> <span class="n">expires</span> <span class="o">&lt;=</span> <span class="n">now</span><span class="p">:</span>
                <span class="k">raise</span> <span class="nc">ValueError</span><span class="p">(</span><span class="sh">"</span><span class="s">expires_at -&gt; Expiration date must be in the future</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">self</span>
</code></pre></div></div>

<p>Breaking down what each element enforces:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">HttpUrl</code></strong> — Pydantic’s built-in type rejects any URL that is not HTTP or HTTPS. No custom validator needed for the scheme.</li>
  <li><strong><a href="https://docs.pydantic.dev/latest/concepts/validators/#field-validators"><code class="language-plaintext highlighter-rouge">@field_validator</code></a></strong> — Enforces the Base62 alphabet rule: only <code class="language-plaintext highlighter-rouge">[a-zA-Z0-9]</code>. This is the <code class="language-plaintext highlighter-rouge">ShortCode</code> Value Object’s constraint, expressed as a Pydantic field-level validator.</li>
  <li><strong><a href="https://docs.pydantic.dev/latest/concepts/validators/#model-validators"><code class="language-plaintext highlighter-rouge">@model_validator(mode="after")</code></a></strong> — Runs after all fields are validated, so it can access <code class="language-plaintext highlighter-rouge">self.expires_at</code>. It rejects expiration dates in the past. This is a cross-field invariant that requires the full model context.</li>
</ul>

<p><img src="/assets/img/fasturl-pydantic-validation.png" alt="Pydantic v2 Validation Workflow" width="760" height="400" class="responsive_img" /></p>

<p>The response schema mirrors the domain structure: <code class="language-plaintext highlighter-rouge">LinkResponse</code> embeds <code class="language-plaintext highlighter-rouge">InspectionResponse</code> and <code class="language-plaintext highlighter-rouge">MetricsResponse</code>, reflecting the <code class="language-plaintext highlighter-rouge">LinkInspection</code> and <code class="language-plaintext highlighter-rouge">LinkMetrics</code> Value Objects in the aggregate.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">LinkResponse</span><span class="p">(</span><span class="n">BaseModel</span><span class="p">):</span>
    <span class="n">code</span><span class="p">:</span> <span class="nb">str</span>
    <span class="n">short_url</span><span class="p">:</span> <span class="nb">str</span>
    <span class="n">target_url</span><span class="p">:</span> <span class="nb">str</span>
    <span class="n">is_active</span><span class="p">:</span> <span class="nb">bool</span>
    <span class="n">expires_at</span><span class="p">:</span> <span class="n">datetime</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span>
    <span class="n">inspection</span><span class="p">:</span> <span class="n">InspectionResponse</span>
    <span class="n">metrics</span><span class="p">:</span> <span class="n">MetricsResponse</span>
    <span class="n">created_at</span><span class="p">:</span> <span class="n">datetime</span>
    <span class="n">updated_at</span><span class="p">:</span> <span class="n">datetime</span>
</code></pre></div></div>

<p>Note that <code class="language-plaintext highlighter-rouge">id</code> — the internal surrogate key — is absent from the response. The <code class="language-plaintext highlighter-rouge">code</code> is the sole public identifier. This is a deliberate design choice: a sequential integer PK is enumerable and guessable; a Base62 7-character code is not.</p>

<h2 id="the-error-envelope">The Error Envelope</h2>

<p>One of the most important consistency decisions in any API is the error contract. FastURL uses a single envelope for every error response:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"success"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="p">,</span><span class="w">
  </span><span class="nl">"error"</span><span class="p">:</span><span class="w"> </span><span class="s2">"LINK_NOT_FOUND"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"details"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="s2">"code -&gt; No link found with code 'aB3x9zK'"</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">error</code> field is a machine-readable short code. The <code class="language-plaintext highlighter-rouge">details</code> list contains human-readable, field-level explanations. This structure lets clients branch on <code class="language-plaintext highlighter-rouge">error</code> programmatically and display <code class="language-plaintext highlighter-rouge">details</code> to a developer or end user.</p>

<p><img src="/assets/img/fasturl-error-envelope.png" alt="FastAPI Standardized Error Envelope &amp; Mapping" width="760" height="400" class="responsive_img" /></p>

<h3 id="domain-exceptions">Domain Exceptions</h3>

<p>Domain exceptions are pure Python classes — no FastAPI imports, no HTTP knowledge. They carry only the data needed to build the error response.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">LinkNotFoundError</span><span class="p">(</span><span class="n">AppError</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">code</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="nf">super</span><span class="p">().</span><span class="nf">__init__</span><span class="p">(</span>
            <span class="n">message</span><span class="o">=</span><span class="sh">"</span><span class="s">LINK_NOT_FOUND</span><span class="sh">"</span><span class="p">,</span>
            <span class="n">status_code</span><span class="o">=</span><span class="mi">404</span><span class="p">,</span>
            <span class="n">details</span><span class="o">=</span><span class="p">[</span><span class="sa">f</span><span class="sh">"</span><span class="s">code -&gt; No link found with code </span><span class="sh">'</span><span class="si">{</span><span class="n">code</span><span class="si">}</span><span class="sh">'"</span><span class="p">],</span>
        <span class="p">)</span>

<span class="k">class</span> <span class="nc">AliasAlreadyTakenError</span><span class="p">(</span><span class="n">AppError</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">alias</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="nf">super</span><span class="p">().</span><span class="nf">__init__</span><span class="p">(</span>
            <span class="n">message</span><span class="o">=</span><span class="sh">"</span><span class="s">ALIAS_ALREADY_TAKEN</span><span class="sh">"</span><span class="p">,</span>
            <span class="n">status_code</span><span class="o">=</span><span class="mi">409</span><span class="p">,</span>
            <span class="n">details</span><span class="o">=</span><span class="p">[</span><span class="sa">f</span><span class="sh">"</span><span class="s">custom_code -&gt; The alias </span><span class="sh">'</span><span class="si">{</span><span class="n">alias</span><span class="si">}</span><span class="sh">'</span><span class="s"> is already in use</span><span class="sh">"</span><span class="p">],</span>
        <span class="p">)</span>

<span class="k">class</span> <span class="nc">LinkExpiredError</span><span class="p">(</span><span class="n">AppError</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">code</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">expired_at</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="nf">super</span><span class="p">().</span><span class="nf">__init__</span><span class="p">(</span>
            <span class="n">message</span><span class="o">=</span><span class="sh">"</span><span class="s">LINK_EXPIRED</span><span class="sh">"</span><span class="p">,</span>
            <span class="n">status_code</span><span class="o">=</span><span class="mi">410</span><span class="p">,</span>
            <span class="n">details</span><span class="o">=</span><span class="p">[</span><span class="sa">f</span><span class="sh">"</span><span class="s">code -&gt; Link </span><span class="sh">'</span><span class="si">{</span><span class="n">code</span><span class="si">}</span><span class="sh">'</span><span class="s"> expired on </span><span class="si">{</span><span class="n">expired_at</span><span class="si">}</span><span class="sh">"</span><span class="p">],</span>
        <span class="p">)</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">AppError</code> base class holds the <code class="language-plaintext highlighter-rouge">message</code>, <code class="language-plaintext highlighter-rouge">status_code</code>, and <code class="language-plaintext highlighter-rouge">details</code>. Subclasses just set the right values in <code class="language-plaintext highlighter-rouge">__init__</code>. The service layer raises these exceptions; the HTTP layer never needs to know how they are structured.</p>

<h3 id="global-exception-handlers">Global Exception Handlers</h3>

<p>Three handlers registered in <code class="language-plaintext highlighter-rouge">main.py</code> <a href="https://fastapi.tiangolo.com/tutorial/handling-errors/">cover every failure path</a>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">register_exception_handlers</span><span class="p">(</span><span class="n">app</span><span class="p">:</span> <span class="n">FastAPI</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>

    <span class="nd">@app.exception_handler</span><span class="p">(</span><span class="n">RequestValidationError</span><span class="p">)</span>
    <span class="k">async</span> <span class="k">def</span> <span class="nf">validation_exception_handler</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">exc</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">JSONResponse</span><span class="p">:</span>
        <span class="n">error_details</span> <span class="o">=</span> <span class="p">[]</span>
        <span class="k">for</span> <span class="n">e</span> <span class="ow">in</span> <span class="n">exc</span><span class="p">.</span><span class="nf">errors</span><span class="p">():</span>
            <span class="n">loc</span> <span class="o">=</span> <span class="sh">"</span><span class="s"> -&gt; </span><span class="sh">"</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="nf">str</span><span class="p">(</span><span class="n">x</span><span class="p">)</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="n">e</span><span class="p">[</span><span class="sh">"</span><span class="s">loc</span><span class="sh">"</span><span class="p">])</span>
            <span class="n">msg</span> <span class="o">=</span> <span class="n">e</span><span class="p">[</span><span class="sh">"</span><span class="s">msg</span><span class="sh">"</span><span class="p">]</span>
            <span class="n">error_details</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">loc</span><span class="si">}</span><span class="s">: </span><span class="si">{</span><span class="n">msg</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">return</span> <span class="nc">JSONResponse</span><span class="p">(</span>
            <span class="n">status_code</span><span class="o">=</span><span class="mi">400</span><span class="p">,</span>
            <span class="n">content</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">success</span><span class="sh">"</span><span class="p">:</span> <span class="bp">False</span><span class="p">,</span> <span class="sh">"</span><span class="s">error</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">VALIDATION_ERROR</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">details</span><span class="sh">"</span><span class="p">:</span> <span class="n">error_details</span><span class="p">},</span>
        <span class="p">)</span>

    <span class="nd">@app.exception_handler</span><span class="p">(</span><span class="n">AppError</span><span class="p">)</span>
    <span class="k">async</span> <span class="k">def</span> <span class="nf">app_error_handler</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">exc</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">JSONResponse</span><span class="p">:</span>
        <span class="k">return</span> <span class="nc">JSONResponse</span><span class="p">(</span>
            <span class="n">status_code</span><span class="o">=</span><span class="n">exc</span><span class="p">.</span><span class="n">status_code</span><span class="p">,</span>
            <span class="n">content</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">success</span><span class="sh">"</span><span class="p">:</span> <span class="bp">False</span><span class="p">,</span> <span class="sh">"</span><span class="s">error</span><span class="sh">"</span><span class="p">:</span> <span class="n">exc</span><span class="p">.</span><span class="n">message</span><span class="p">,</span> <span class="sh">"</span><span class="s">details</span><span class="sh">"</span><span class="p">:</span> <span class="n">exc</span><span class="p">.</span><span class="n">details</span><span class="p">},</span>
        <span class="p">)</span>

    <span class="nd">@app.exception_handler</span><span class="p">(</span><span class="nb">Exception</span><span class="p">)</span>
    <span class="k">async</span> <span class="k">def</span> <span class="nf">global_exception_handler</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">exc</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">JSONResponse</span><span class="p">:</span>
        <span class="c1"># Full traceback logged server-side; generic message returned to client
</span>        <span class="k">return</span> <span class="nc">JSONResponse</span><span class="p">(</span>
            <span class="n">status_code</span><span class="o">=</span><span class="mi">500</span><span class="p">,</span>
            <span class="n">content</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">success</span><span class="sh">"</span><span class="p">:</span> <span class="bp">False</span><span class="p">,</span> <span class="sh">"</span><span class="s">error</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">INTERNAL_SERVER_ERROR</span><span class="sh">"</span><span class="p">,</span>
                     <span class="sh">"</span><span class="s">details</span><span class="sh">"</span><span class="p">:</span> <span class="p">[</span><span class="sh">"</span><span class="s">An unexpected error occurred. Please contact support.</span><span class="sh">"</span><span class="p">]},</span>
        <span class="p">)</span>
</code></pre></div></div>

<p>The three handlers map precisely to the three failure categories:</p>

<table>
  <thead>
    <tr>
      <th>Handler</th>
      <th>Triggered by</th>
      <th>HTTP status</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">RequestValidationError</code></td>
      <td>Pydantic schema validation failure</td>
      <td>400</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">AppError</code></td>
      <td>Domain rule violation (not found, conflict, expired)</td>
      <td>404 / 409 / 410</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Exception</code></td>
      <td>Any unhandled error</td>
      <td>500</td>
    </tr>
  </tbody>
</table>

<p>The third handler is the safety net. It logs the full traceback server-side but returns only a generic message to the client — no stack traces, no internal details, no information disclosure.</p>

<h2 id="the-full-endpoint-table">The Full Endpoint Table</h2>

<p>Bringing everything together, here is the complete API surface of FastURL:</p>

<table>
  <thead>
    <tr>
      <th>Endpoint</th>
      <th>Method</th>
      <th>Status codes</th>
      <th>Description</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/api/v1/links</code></td>
      <td><code class="language-plaintext highlighter-rouge">POST</code></td>
      <td>201, 400, 409</td>
      <td>Create a shortened link</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/api/v1/links</code></td>
      <td><code class="language-plaintext highlighter-rouge">GET</code></td>
      <td>200, 400</td>
      <td>List links with optional filters</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/api/v1/links/{code}</code></td>
      <td><code class="language-plaintext highlighter-rouge">GET</code></td>
      <td>200, 404</td>
      <td>Retrieve a single link</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/api/v1/links/{code}</code></td>
      <td><code class="language-plaintext highlighter-rouge">DELETE</code></td>
      <td>204, 404</td>
      <td>Soft-delete a link</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/api/v1/links/{code}/inspect</code></td>
      <td><code class="language-plaintext highlighter-rouge">POST</code></td>
      <td>202, 404</td>
      <td>Trigger background re-inspection</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/{code}</code></td>
      <td><code class="language-plaintext highlighter-rouge">GET</code></td>
      <td>307, 404, 410</td>
      <td>Redirect to target URL</td>
    </tr>
  </tbody>
</table>

<p>A few design decisions worth noting:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">DELETE</code> returns <code class="language-plaintext highlighter-rouge">204 No Content</code> — no body, because there is nothing meaningful to return after a deletion.</li>
  <li><code class="language-plaintext highlighter-rouge">POST /api/v1/links/{code}/inspect</code> returns <code class="language-plaintext highlighter-rouge">202 Accepted</code> — not <code class="language-plaintext highlighter-rouge">200 OK</code>. The inspection runs asynchronously; the client receives the current snapshot immediately and polls <code class="language-plaintext highlighter-rouge">GET /api/v1/links/{code}</code> for the result.</li>
  <li><code class="language-plaintext highlighter-rouge">GET /{code}</code> can return <code class="language-plaintext highlighter-rouge">410 Gone</code> for expired links. <code class="language-plaintext highlighter-rouge">410</code> is semantically different from <code class="language-plaintext highlighter-rouge">404</code>: it signals that the resource existed but is permanently unavailable, not that it was never found.</li>
</ul>

<h2 id="conclusion">Conclusion</h2>

<p>In this article we covered:</p>

<ul>
  <li><strong>From job stories to domain entities</strong>: how the nouns and verbs in requirements directly produce the <code class="language-plaintext highlighter-rouge">Link</code> aggregate, the <code class="language-plaintext highlighter-rouge">ShortCode</code> and <code class="language-plaintext highlighter-rouge">TargetUrl</code> Value Objects, and the <code class="language-plaintext highlighter-rouge">LinkInspection</code> and <code class="language-plaintext highlighter-rouge">LinkMetrics</code> embedded snapshots.</li>
  <li><strong>Pydantic as Value Object implementation</strong>: using <code class="language-plaintext highlighter-rouge">HttpUrl</code>, <code class="language-plaintext highlighter-rouge">@field_validator</code>, and <code class="language-plaintext highlighter-rouge">@model_validator</code> to enforce domain invariants at the HTTP boundary — before the service layer is called.</li>
  <li><strong>A consistent error envelope</strong>: a single <code class="language-plaintext highlighter-rouge">{success, error, details}</code> structure for every failure, backed by domain exceptions that carry no HTTP knowledge and three global handlers that translate them.</li>
  <li><strong>Resource hierarchy derived from the domain</strong>: five management endpoints plus a public redirect endpoint, each with a clear ownership and a precise set of status codes.</li>
</ul>

<p>The <a href="/layered-architecture-with-fastapi-routers-services-and-repositories/">next article</a> walks through the layered code structure that makes all of this work: how routers, services, repositories, and the dependency injection system are organized to keep each concern in its own place.</p>]]></content><author><name>Salvatore D&apos;Angelo</name></author><category term="Programming" /><category term="programming" /><category term="python" /><summary type="html"><![CDATA[Turn requirements and a domain model into a clean REST API with FastAPI and Pydantic v2, using FastURL, a URL shortener, as a concrete case study.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://code4projects.org/assets/img/from-domain-design-to-rest-api-building-fasturl-with-fastapi-hero.png" /><media:content medium="image" url="https://code4projects.org/assets/img/from-domain-design-to-rest-api-building-fasturl-with-fastapi-hero.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Designing REST APIs in Practice: A Kubernetes Case Study</title><link href="https://code4projects.org/designing-rest-apis-in-practice-a-kubernetes-case-study/" rel="alternate" type="text/html" title="Designing REST APIs in Practice: A Kubernetes Case Study" /><published>2026-09-16T00:00:00+00:00</published><updated>2026-09-16T00:00:00+00:00</updated><id>https://code4projects.org/designing-rest-apis-in-practice-a-kubernetes-case-study</id><content type="html" xml:base="https://code4projects.org/designing-rest-apis-in-practice-a-kubernetes-case-study/"><![CDATA[<p><img src="/assets/img/designing-rest-apis-in-practice-a-kubernetes-case-study-hero.png" alt="Designing REST APIs in Practice: A Kubernetes Case Study" width="760" height="400" class="responsive_img" /></p>

<h2 id="introduction">Introduction</h2>

<p>In the previous installments of this series, we explored CLI application patterns with <a href="/building-a-python-cli-with-click-a-cleaner-approach-to-the-command-pattern/">Click</a> and <a href="/building-a-python-cli-with-typer-the-modern-approach-to-the-command-pattern/">Typer</a>. While CLI interfaces offer a convenient way for developers to interact with services locally, building distributed applications requires exposing services over the network via structured HTTP interfaces.</p>

<p>When designing a REST API, engineers often focus on choosing the “right” framework — FastAPI, Spring Boot, and so on. Tools matter, but they solve only the implementation detail. The real challenge is designing an API that reflects the business domain, exposes domain entities as resources, and uses HTTP semantics consistently, including errors and status codes.</p>

<p><strong>Think of an API as a contract between two systems</strong>. As the designer of this contract, you act like a <strong>notary</strong>, carefully drafting the terms of interaction. However, this contract must follow the <strong>rules of HTTP</strong>, ensuring it adheres to the standard conventions for communication.</p>

<p>The first step is to identify the <strong>entities</strong> involved in the contract. Once these entities are clear, the next step is to define the <strong>rules</strong>: what operations can be performed on each entity, and under what conditions, all while respecting the HTTP framework.</p>

<p>A good API should be intuitive, self-describing, and coherent with the domain. This requires identifying business entities, their lifecycle, and their relationships before thinking about technology. Once the domain model is clear, the API becomes almost a direct consequence of it.</p>

<p>To avoid remaining purely theoretical, we will use Kubernetes as a case study. It provides a concrete example where functional requirements naturally lead to entities and operations that are directly exposed through an HTTP interface.</p>

<p>You should read this article if:</p>

<ul>
  <li>You are designing or refactoring a REST API and want a rigorous, entity-driven methodology.</li>
  <li>You want to understand how domain concepts translate into REST resources, HTTP methods, and status codes.</li>
  <li>You want to see how Kubernetes models its complex architecture cleanly using standard REST conventions.</li>
</ul>

<h2 id="from-requirements-to-entities-the-logical-model-of-kubernetes">From requirements to entities: the logical model of Kubernetes</h2>

<p>In a <a href="https://www.amazon.it/-/en/Clean-Architecture-Craftsmans-Software-Structure/dp/0134494164">Clean Architecture</a> perspective, the design process begins from the inside: <strong>entities at the center</strong>, then <strong>use cases</strong>, then the <strong>external interfaces</strong>.</p>

<p><img src="/assets/img/designing-rest-apis-in-practice-a-kubernetes-case-study-clean-architecture.png" alt="Clean Architecture Main Concepts" width="760" height="400" class="responsive_img" />
<em>Clean Architecture Main Concepts</em></p>

<p>User stories play a fundamental role here. They describe what the system must accomplish from a business perspective, and in doing so they implicitly define the <strong>domain language</strong>: the nouns in the stories become the <strong>entities</strong> of the domain, while the verbs describe the operations and relationships between them.</p>

<p>Kubernetes is no exception. Its functional requirements — scheduling workloads, distributing containers, maintaining stable network access, scaling services, enforcing security boundaries — naturally give rise to a set of domain entities. These entities exist independently of any API design choice; the API merely exposes them.</p>

<p>In this section, I assume the requirements of Kubernetes are already known and focus directly on their consequences: the entities that form the system and the relationships between them.</p>

<h3 id="resource-entities">Resource entities</h3>

<p><img src="/assets/img/designing-rest-apis-in-practice-a-kubernetes-case-study-entities-relationships.png" alt="Kubernetes Entities with their Relationships" width="760" height="400" class="responsive_img" />
<em>Kubernetes Entities with their Relationships</em></p>

<p>In Kubernetes everything is a Resource. Conceptually, Kubernetes exposes two main categories of resources:</p>

<ul>
  <li><strong>Namespaced resources</strong> — exist within a specific namespace</li>
  <li><strong>Cluster-scoped resources</strong> — global across the entire cluster</li>
</ul>

<p>Although the “cluster” itself is not a first-class API object, we can treat it as the top-level element for reasoning about relationships.</p>

<ul>
  <li>A <strong>cluster</strong> consists of one or more <strong>nodes</strong> or <strong>workers</strong>.</li>
  <li>Each <strong>node</strong> runs one or more <em>*Pods</em>+.</li>
  <li>Each <strong>Pod</strong> contains one or more <strong>containers</strong>.</li>
</ul>

<p>Pods are ephemeral and their network identity is unstable. For this reason, Pods are typically exposed through a <strong>Service</strong> (namespaced resource). Services come in three main variants:</p>

<ul>
  <li><strong>ClusterIP</strong></li>
  <li><strong>NodePort</strong></li>
  <li><strong>LoadBalancer</strong></li>
</ul>

<p>Workloads are created and managed by Workload (this concept doesn’t explicitly exist in Kubernetes but it helps us to model the resources). The most common ones are:</p>

<ul>
  <li><strong>Deployments</strong>, used for stateless applications</li>
  <li><strong>StatefulSets</strong>, used for stateful applications</li>
  <li><strong>DaemonSets</strong>, used to run one Pod instance on every (or selected) node</li>
  <li><strong>Jobs</strong>, used for finite, one-off batch operations</li>
  <li><strong>CronJobs</strong>, used for scheduled batch workloads</li>
</ul>

<p>Pods may rely on persistent storage. They reference one or more <strong>PersistentVolumeClaims (PVCs)</strong>, each of which binds to a <strong>PersistentVolume (PV)</strong> in a one-to-one manner.</p>

<p>Finally, users are not represented by Kubernetes YAML objects but are defined in the Kubernetes configuration (commonly via the kubeconfig file). Users acquire permissions through <strong>Roles</strong> (namespaced) and <strong>ClusterRoles</strong> (cluster-scoped). Roles are connected to users through <strong>RoleBindings</strong> or <strong>ClusterRoleBindings</strong>, depending on the scope.</p>

<p>Once this entity model is clear, designing a RESTful API becomes almost mechanical. Each entity maps directly to a resource, operations correspond to the entity’s lifecycle, and access control follows from the relationships defined in the domain model.</p>

<h2 id="defining-the-rules-of-an-api">Defining the Rules of an API</h2>

<p>Once we have identified the entities in the system, the next step is to define the <strong>rules</strong> of interaction between these entities. These rules will dictate how resources can be accessed, manipulated, or deleted via the API. Each rule can be broken down into four main components:</p>

<p><img src="/assets/img/designing-rest-apis-in-practice-a-kubernetes-case-study-api-rules.png" alt="Defining the Rules of an API" width="760" height="400" class="responsive_img" />
<em>Defining the Rules of an API</em></p>

<h3 id="1-endpoint">1. Endpoint</h3>

<p>The <strong>endpoint</strong> is the URL pattern that maps to the entity and defines the resource’s location. It is the path that the client will use to access the resource or perform an action on it.</p>

<p>A few important principles:</p>

<p><strong>Use plural names for entities:</strong></p>

<p>Even when retrieving a single entity, the resource name in the endpoint should be plural to denote the collection.</p>

<p>Example:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">/api/v1/namespaces/{namespace}/pods          # collection of pods
/api/v1/namespaces/{namespace}/pods/{pod_id}  # single pod
</span></code></pre></div></div>

<p>Using the plural form consistently improves readability, makes endpoints predictable, and clearly distinguishes between collections and individual entities.</p>

<p><strong>Limit relationship depth to two entities:</strong></p>

<p>When representing relationships between entities in the endpoint, it is recommended to limit the path to two entities (e.g., <code class="language-plaintext highlighter-rouge">/api/v1/customers/{customer_id}/employees</code>). Going beyond this introduces unnecessary complexity and can lead to performance issues, as deeper relationships make the endpoint more difficult to manage and can impact scalability. Keeping the representation simple ensures better performance and maintainability.</p>

<h3 id="2-operation">2. Operation</h3>

<p>The operation specifies the action to be performed on the resource. This is determined by the HTTP method used in the request. The most common HTTP methods include:</p>

<table>
  <thead>
    <tr>
      <th>Method</th>
      <th>Purpose</th>
      <th style="text-align: center">Idempotent by spec</th>
      <th style="text-align: center">Idempotent by design</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">GET</code></td>
      <td>Retrieve a resource or a collection</td>
      <td style="text-align: center">✅ Yes</td>
      <td style="text-align: center">—</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">POST</code></td>
      <td>Create a new resource</td>
      <td style="text-align: center">❌ No</td>
      <td style="text-align: center">⚠️ Possible</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">PUT</code></td>
      <td>Replace the entire resource representation</td>
      <td style="text-align: center">✅ Yes</td>
      <td style="text-align: center">—</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">PATCH</code></td>
      <td>Apply partial updates</td>
      <td style="text-align: center">❌ No</td>
      <td style="text-align: center">⚠️ Possible</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DELETE</code></td>
      <td>Remove a resource</td>
      <td style="text-align: center">❌ No</td>
      <td style="text-align: center">⚠️ Possible</td>
    </tr>
  </tbody>
</table>

<p><strong>Idempotent by spec</strong> reflects what the HTTP specification (RFC 9110) mandates for the method.
<strong>Idempotent by design</strong> marks methods that are not idempotent by spec but can be made so through deliberate implementation choices:</p>

<ul>
  <li><strong>POST</strong> — A payment system must not charge a customer twice on retried requests. This is solved with an <code class="language-plaintext highlighter-rouge">Idempotency-Key</code> header: the server stores the result of the first call and returns it unchanged on subsequent calls with the same key.</li>
  <li><strong>PATCH</strong> — A partial update that sets an absolute value (e.g., <code class="language-plaintext highlighter-rouge">{"status": "active"}</code>) is idempotent; one that applies a relative delta (e.g., <code class="language-plaintext highlighter-rouge">{"counter": "+1"}</code>) is not.</li>
  <li><strong>DELETE</strong> — A soft delete (setting a <code class="language-plaintext highlighter-rouge">deleted_at</code> timestamp or a <code class="language-plaintext highlighter-rouge">status=deleted</code> flag) makes the operation idempotent: a second call finds the resource already marked as deleted and returns the same successful response.</li>
</ul>

<p>Some actions, like <strong>restarting a Pod</strong>, are not CRUD operations but can be modeled using <strong>POST</strong>, as they trigger an action on a resource without modifying it directly.</p>

<h3 id="3-parameters">3. Parameters</h3>

<p>Parameters define the information needed to execute the operation. There are three types of parameters that can be used:</p>

<ul>
  <li><strong>URL Parameters:</strong> These are part of the endpoint and are typically used to specify the resource’s identity, such as a specific resource ID (e.g., <code class="language-plaintext highlighter-rouge">/pods/{pod_id}</code>).</li>
  <li><strong>Query Parameters:</strong> These are appended to the URL to refine or filter the results. They can specify things like pagination, sorting, or specific filters (e.g., <code class="language-plaintext highlighter-rouge">?limit=10</code> or <code class="language-plaintext highlighter-rouge">?status=active</code>).</li>
  <li><strong>Body Parameters:</strong> These are included in the body of the request and are typically used with methods like POST, PUT, and PATCH. Body parameters allow you to send complex data structures like JSON or XML to define the resource’s state or request changes.</li>
</ul>

<h3 id="4-status-code">4. Status Code</h3>

<p>The status code is the HTTP response code that indicates the result of the operation. Common status codes include:</p>

<ul>
  <li><strong>200 OK:</strong> The request was successful (used for GET, PUT, DELETE).</li>
  <li><strong>201 Created:</strong> The resource was successfully created (used for POST).</li>
  <li><strong>400 Bad Request:</strong> The request was malformed or invalid.</li>
  <li><strong>404 Not Found:</strong> The resource was not found.</li>
  <li><strong>405 Method Not Allowed:</strong> The requested method (i.e. GET, POST, etc.) has not been implemented for the specified resource.</li>
  <li><strong>409 Conflict:</strong> Conflicting state or rate limit reached.</li>
  <li><strong>500 Internal Server Error:</strong> There was an error on the server side.</li>
  <li><strong>503 Bad Gateway / Service Unavailable:</strong> Usually when a 3rd party subsystem is unavailable.</li>
</ul>

<h3 id="5-response">5. Response</h3>

<p>The <strong>response</strong> is the data returned from the server after processing the request. This can include:</p>

<ul>
  <li>A <strong>representation of the resource</strong> (for GET requests).</li>
  <li>A <strong>confirmation message</strong> (for POST, PUT, or DELETE requests, indicating success or failure).</li>
  <li>A <strong>failure message</strong> (for errors, explaining what went wrong).</li>
</ul>

<p>The response should be formatted in a structured and predictable way (typically JSON or XML) so that the client can easily parse and understand the result.</p>

<p>These components — <strong>endpoint</strong>, <strong>operation</strong>, <strong>parameters</strong>, <strong>status code</strong>, and <strong>response</strong> — form the backbone of any API rule. By clearly defining these elements, you ensure that the API is intuitive, consistent, and easy to use for clients. These rules are the foundation that will guide how entities interact with each other in a system, ensuring that the API remains predictable, maintainable, and aligned with business needs.</p>

<h2 id="managing-a-pod-via-api">Managing a Pod via API</h2>

<p>Since it’s impossible in an article cover the API for all the above mentioned resources, let’s see how to define the API for the <strong>Pod</strong> resource.</p>

<p>In Kubernetes, these are the typical <a href="https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#podspec-v1-core">operations you can perform on a Pod</a>, including both <strong>CRUD operations</strong> and a <strong>non-CRUD action</strong> like restarting a Pod.</p>

<p><img src="/assets/img/designing-rest-apis-in-practice-a-kubernetes-case-study-managing-pod.png" alt="Managing Pod via API" width="760" height="400" class="responsive_img" />
<em>Managing Pod via API</em></p>

<h3 id="1-create-post">1. CREATE (POST)</h3>

<ul>
  <li><strong>Endpoint:</strong> <code class="language-plaintext highlighter-rouge">/api/v1/namespaces/{namespace}/pods</code></li>
  <li><strong>Operation:</strong> POST</li>
  <li><strong>Parameters:</strong> Body (JSON representation of the Pod specification, equivalent to what would be defined in a YAML file)</li>
  <li><strong>Description:</strong> Create a new Pod in the specified namespace. The JSON body contains the Pod definition, including metadata and specification (containers, images, etc.).</li>
  <li><strong>Status Code:</strong> 201 Created</li>
  <li><strong>Response:</strong> Details of the created Pod.</li>
</ul>

<h3 id="2-read-get">2. READ (GET)</h3>

<ul>
  <li><strong>Endpoint:</strong> <code class="language-plaintext highlighter-rouge">/api/v1/namespaces/{namespace}/pods</code> (to list all Pods in a namespace) or <code class="language-plaintext highlighter-rouge">/api/v1/namespaces/{namespace}/pods/{pod_id}</code> (to retrieve a specific Pod)</li>
  <li><strong>Operation:</strong> GET</li>
  <li><strong>Parameters:</strong> URL parameters (<code class="language-plaintext highlighter-rouge">namespace</code>, <code class="language-plaintext highlighter-rouge">pod_id</code>), Query parameters (e.g., <code class="language-plaintext highlighter-rouge">?status=running</code>)</li>
  <li><strong>Description:</strong> Retrieve information about a specific Pod or a list of Pods.</li>
  <li><strong>Status Code:</strong> 200 OK</li>
  <li><strong>Response:</strong> The Pod’s details (e.g., metadata, status, containers).</li>
</ul>

<h3 id="3-update-put">3. UPDATE (PUT)</h3>

<ul>
  <li><strong>Endpoint:</strong> <code class="language-plaintext highlighter-rouge">/api/v1/namespaces/{namespace}/pods/{pod_id}</code></li>
  <li><strong>Operation:</strong> PUT</li>
  <li><strong>Parameters:</strong> Body (Updated Pod specification in JSON)</li>
  <li><strong>Description:</strong> Replace an existing Pod’s specification with a new one (e.g., updating resources).</li>
  <li><strong>Status Code:</strong> 200 OK</li>
  <li><strong>Response:</strong> The updated Pod details.</li>
</ul>

<h3 id="4-partial-update-patch">4. PARTIAL UPDATE (PATCH)</h3>

<ul>
  <li><strong>Endpoint:</strong> <code class="language-plaintext highlighter-rouge">/api/v1/namespaces/{namespace}/pods/{pod_id}</code></li>
  <li><strong>Operation:</strong> PATCH</li>
  <li><strong>Parameters:</strong> Body (Partial update, e.g., only changing the labels or annotations).</li>
  <li><strong>Description:</strong> Apply partial updates to a Pod’s specification, such as modifying labels, annotations, or other attributes without replacing the entire Pod.</li>
  <li><strong>Status Code:</strong> 200 OK</li>
  <li><strong>Response:</strong> The updated Pod details.</li>
</ul>

<h3 id="5-delete-delete">5. DELETE (DELETE)</h3>

<ul>
  <li><strong>Endpoint:</strong> <code class="language-plaintext highlighter-rouge">/api/v1/namespaces/{namespace}/pods/{pod_id}</code></li>
  <li><strong>Operation:</strong> DELETE</li>
  <li><strong>Parameters:</strong> URL parameters (<code class="language-plaintext highlighter-rouge">namespace</code>, <code class="language-plaintext highlighter-rouge">pod_id</code>)</li>
  <li><strong>Description:</strong> Delete the specified Pod.</li>
  <li><strong>Status Code:</strong> 200 OK, 202 Accepted if the deletion is asynchronous, or 204 No Content if the operation is successfully but there is no content in the response.</li>
  <li><strong>Response:</strong> Confirmation of deletion.</li>
</ul>

<h2 id="authentication-and-authorization-in-apis-kubernetes-as-a-case-study">Authentication and Authorization in APIs (Kubernetes as a Case Study)</h2>

<p>In any API, <strong>authentication</strong> and <strong>authorization</strong> are critical to ensure that only legitimate users can perform allowed operations on the system’s resources. Conceptually, these principles apply to all APIs, not just Kubernetes.</p>

<p><img src="/assets/img/designing-rest-apis-in-practice-a-kubernetes-case-study-auth.png" alt="Authentication and Authorization" width="760" height="400" class="responsive_img" />
<em>Authentication and Authorization</em></p>

<h3 id="1-authentication">1. Authentication</h3>

<ul>
  <li><strong>Authentication</strong> verifies the identity of a user or client.</li>
  <li>Typically, a user presents a credential, such as a <strong>client ID and secret</strong>, a <strong>JWT token</strong>, or a certificate.</li>
  <li>The application validates the credential and extracts the user identity.</li>
</ul>

<blockquote>
  <p><strong>Principle:</strong> If the identity cannot be verified, the API must return <strong>HTTP 401 Unauthorized</strong>.</p>
</blockquote>

<h3 id="2-authorization">2. Authorization</h3>

<ul>
  <li><strong>Authorization</strong> determines what the authenticated user can do.</li>
  <li>Once a user is identified, the system maps the user to <strong>roles</strong>, and roles define permissions on resources.</li>
  <li>Each role can be scoped to a part of the system (i.e. in Kubernetes <strong>Namespaced resources</strong> or <strong>Cluster-scoped resources</strong>).</li>
</ul>

<blockquote>
  <p><strong>Principle:</strong> If the user is authenticated but lacks permission for an operation, the API must return <strong>HTTP 403 Forbidden</strong>.</p>
</blockquote>

<h3 id="3-role-mapping-and-bindings-case-study-kubernetes">3. Role Mapping and Bindings (Case Study: Kubernetes)</h3>

<p>When you design your application you need to:</p>

<ul>
  <li>Define a set of roles;</li>
  <li>Define a scope for each role;</li>
  <li>Define the operations the role can do in that scope.</li>
</ul>

<p>Kubernetes illustrates these concepts:</p>

<ul>
  <li>A <strong>Role</strong> defines operations allowed on namespaced resources (e.g., Pods, Services).</li>
  <li>A <strong>ClusterRole</strong> defines operations allowed on cluster-scoped resources (e.g., Nodes, PersistentVolumes).</li>
  <li>Users are associated to roles via:
    <ul>
      <li><strong>RoleBinding</strong> → links user to a Role in a namespace</li>
      <li><strong>ClusterRoleBinding</strong> → links user to a ClusterRole cluster-wide</li>
    </ul>
  </li>
</ul>

<p>This design allows a many-to-many mapping: users can have multiple roles, and roles can apply to multiple users.</p>

<h3 id="4-context-of-operation">4. Context of Operation</h3>

<p>When a user interacts with the API, the system maintains a context that encapsulates all relevant information:</p>

<ul>
  <li><strong>User identity</strong> — who is making the request</li>
  <li><strong>Roles</strong> — the permissions assigned to this user</li>
  <li><strong>Scope</strong> — the resource context (namespaced or cluster-scoped) where operations are allowed</li>
</ul>

<p>This context is used by the application to enforce authorization rules consistently for each request.</p>

<h3 id="5-key-takeaways-for-api-design">5. Key Takeaways for API Design</h3>

<ul>
  <li>Authentication and authorization should be <strong>explicit and predictable</strong>: unauthorized requests → 401, forbidden operations → 403.</li>
  <li>Roles should be mapped clearly to resources, respecting their scope.</li>
  <li>Maintaining a user context makes it easier to check permissions and enforce rules consistently.</li>
</ul>

<p>Kubernetes demonstrates this clean separation of identity (authentication), capability (authorization), and scope (context), providing a model that can be applied to any API design.</p>

<h2 id="error-handling-in-apis">Error Handling in APIs</h2>

<p>One of the key differences between a professional API and one created by inexperienced developers is how errors are handled. Proper error handling ensures that clients can reliably understand what went wrong and respond appropriately.</p>

<p>While frameworks like <strong>FastAPI</strong> (Python) or <strong>Spring Boot</strong> (Java) provide built-in mechanisms for error handling, the principles are framework-agnostic.</p>

<p><img src="/assets/img/designing-rest-apis-in-practice-a-kubernetes-case-study-error-handling.png" alt="API Error Handling" width="760" height="400" class="responsive_img" />
<em>API Error Handling</em></p>

<h3 id="1-resource-not-found-404">1. Resource Not Found (404)</h3>

<ul>
  <li>When performing operations like <strong>GET</strong>, <strong>PUT</strong>, <strong>PATCH</strong>, <strong>DELETE</strong> on a resource identified by an ID, if the resource does not exist, the API must return <strong>404 Not Found</strong>.</li>
  <li>Returning 200 OK with a message like “resource not found” is incorrect, because clients rely on the HTTP status code to handle errors programmatically.</li>
</ul>

<p>Example:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">GET /api/v1/namespaces/default/pods/nonexistent
Response: 404 Not Found
</span></code></pre></div></div>

<h3 id="2-validation-errors-400-422">2. Validation Errors (400, 422)</h3>

<p>If the client sends invalid input, such as:</p>

<ul>
  <li>Missing required fields</li>
  <li>Incorrect data types</li>
  <li>Malformed JSON</li>
</ul>

<p>the API should return <strong>400 Bad Request</strong> or <strong>422 Unprocessable Entity</strong>.</p>

<p>Frameworks like FastAPI + Pydantic or Spring Boot with validation annotations make it easy to automatically validate input and return the appropriate status code.</p>

<h3 id="3-authentication-and-authorization-errors-401-403">3. Authentication and Authorization Errors (401, 403)</h3>

<ul>
  <li><strong>401 Unauthorized:</strong> The client’s identity could not be verified (e.g., missing or invalid token).</li>
  <li><strong>403 Forbidden:</strong> The client is authenticated but does not have permission to perform the requested operation.</li>
</ul>

<p>These errors were covered in the Authentication and Authorization section, but they are a crucial part of robust error handling.</p>

<h3 id="4-method-not-allowed-405">4. Method Not Allowed (405)</h3>

<p><strong>405 Method Not Allowed</strong> is returned when the client attempts to use an HTTP method that is not supported for a given resource.</p>

<p>Example:</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">POST /api/v1/customers/{id}
Response: 405 Method Not Allowed
</span></code></pre></div></div>

<p>This error is typically <strong>implemented automatically by the framework</strong> (e.g., FastAPI or Spring Boot), based on how routes and HTTP methods are defined. The application logic should not manually handle this case.</p>

<h3 id="5-rate-limiting-429">5. Rate Limiting (429)</h3>

<p><strong>429 Too Many Requests</strong> indicates that the client has exceeded the allowed request rate.</p>

<p>This error is:</p>

<ul>
  <li><strong>Typically enforced at the API gateway or infrastructure level</strong></li>
  <li><strong>Not implemented inside the application code</strong></li>
</ul>

<p>Common use cases include:</p>

<ul>
  <li>Abuse prevention</li>
  <li>Traffic control</li>
  <li>Fair usage policies</li>
</ul>

<p>The response often includes headers such as <code class="language-plaintext highlighter-rouge">Retry-After</code> to indicate when the client can retry.</p>

<h3 id="6-server-errors-500-503">6. Server Errors (500, 503)</h3>

<ul>
  <li><strong>500 Internal Server Error:</strong> Indicates unexpected failures, such as programming errors or unhandled exceptions.</li>
  <li><strong>503 Service Unavailable:</strong> Indicates that a dependency is temporarily unavailable, for example when the API cannot reach the database or an external service is down.</li>
</ul>

<p>These codes allow clients to distinguish between a system failure and a temporary unavailability.</p>

<h2 id="api-documentation">API Documentation</h2>

<p>APIs should be <strong>self-describing</strong>, meaning that each endpoint should be clear and understandable without requiring external documentation. However, tools like <strong>Swagger</strong> have greatly simplified the publication and management of API documentation. Frameworks such as <strong>FastAPI</strong> allow you to automatically generate Swagger documentation for every API, making it interactive and easily accessible.</p>

<p><img src="/assets/img/designing-rest-apis-in-practice-a-kubernetes-case-study-swagger-docs.png" alt="API Documentation with Swagger" width="760" height="400" class="responsive_img" />
<em>API Documentation with Swagger</em></p>

<p>When using Swagger, it’s a best practice to group APIs by the entities they belong to. In FastAPI, this can be achieved using <strong>tags</strong>. For example, all APIs related to a specific entity, such as Customer, can be grouped under the Customer tag. This approach helps to keep the documentation clear and well-structured, with each group of APIs representing the “rules” for that particular entity.</p>

<p>For every API, it’s essential to properly document the following details in the code:</p>

<ul>
  <li><strong>Summary:</strong> A brief description of what the API does, allowing users to quickly understand its purpose.</li>
  <li><strong>Description:</strong> A more detailed explanation of the API, providing additional context on its behavior.</li>
  <li><strong>Parameters:</strong> A list of all the parameters the API accepts, along with information about their type and whether they are required.</li>
  <li><strong>Error Codes:</strong> A list of possible error codes the API may return, along with brief explanations of what each code means.</li>
</ul>

<p>These details will automatically be included in the Swagger documentation, making it comprehensive and easy to navigate.</p>

<p>Moreover, Swagger also acts as a client, allowing developers to directly test the APIs from the documentation interface, which simplifies testing and debugging during development.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this article we covered:</p>

<ul>
  <li><strong>Entity modeling from requirements</strong>: How Clean Architecture principles and user stories identify domain entities and their boundaries prior to writing any API code.</li>
  <li><strong>Resource mappings and relationships</strong>: Structuring RESTful endpoints with plural naming conventions and shallow nesting hierarchies (maximum 2 entities).</li>
  <li><strong>HTTP semantics and operations</strong>: Mapping CRUD and action-based workflows to standard HTTP methods (<code class="language-plaintext highlighter-rouge">GET</code>, <code class="language-plaintext highlighter-rouge">POST</code>, <code class="language-plaintext highlighter-rouge">PUT</code>, <code class="language-plaintext highlighter-rouge">PATCH</code>, <code class="language-plaintext highlighter-rouge">DELETE</code>) with predictable status codes.</li>
  <li><strong>Authentication, authorization, and context</strong>: Enforcing access controls via roles, scopes, and execution contexts, using Kubernetes RBAC (<code class="language-plaintext highlighter-rouge">Role</code>, <code class="language-plaintext highlighter-rouge">ClusterRole</code>, <code class="language-plaintext highlighter-rouge">RoleBinding</code>) as an architectural blueprint.</li>
  <li><strong>Standardized error handling and Swagger documentation</strong>: Returning machine-readable status codes and using tags and OpenAPI metadata to generate self-documenting APIs.</li>
</ul>

<p>The next article will put these architectural principles into practice: building a production-ready asynchronous REST API using <strong>FastAPI</strong>, with thread pools for blocking operations and dependency injection patterns.</p>]]></content><author><name>Salvatore D&apos;Angelo</name></author><category term="Programming" /><category term="programming" /><category term="kubernetes" /><category term="python" /><summary type="html"><![CDATA[Learn how to design robust, domain-driven REST APIs using HTTP semantics and clean architecture principles, using Kubernetes as a practical case study.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://code4projects.org/assets/img/designing-rest-apis-in-practice-a-kubernetes-case-study-hero.png" /><media:content medium="image" url="https://code4projects.org/assets/img/designing-rest-apis-in-practice-a-kubernetes-case-study-hero.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Async and the Event Loop in Python: asyncio in Practice</title><link href="https://code4projects.org/async-and-event-loop-in-python-asyncio-in-practice/" rel="alternate" type="text/html" title="Async and the Event Loop in Python: asyncio in Practice" /><published>2026-09-13T00:00:00+00:00</published><updated>2026-09-13T00:00:00+00:00</updated><id>https://code4projects.org/async-and-event-loop-in-python-asyncio-in-practice</id><content type="html" xml:base="https://code4projects.org/async-and-event-loop-in-python-asyncio-in-practice/"><![CDATA[<p><img src="/assets/img/async-and-event-loop-in-python-asyncio-in-practice-hero.jpg" alt="Async and the Event Loop in Python: asyncio in Practice" width="760" height="400" class="responsive_img" /></p>

<h2 id="introduction">Introduction</h2>

<p>The <a href="/concurrency-in-python-threads-processes-and-the-event-loop/">previous article</a> covered the theory: the GIL, thread pools, process pools, and why the event loop beats them all for I/O-bound concurrency at scale. Theory is useful until you sit down to write something real.</p>

<p>This article builds a concrete project from scratch — an async news aggregator that fetches RSS feeds concurrently and formats a digest ready to send via WhatsApp. The code is the same used in the open-source <a href="https://github.com/sasadangelo/alfred">Alfred project</a>: it starts minimal and grows with one new pattern per section: <code class="language-plaintext highlighter-rouge">gather</code>, <code class="language-plaintext highlighter-rouge">TaskGroup</code>, <code class="language-plaintext highlighter-rouge">asyncio.timeout</code>, <code class="language-plaintext highlighter-rouge">Semaphore</code>, and <code class="language-plaintext highlighter-rouge">format_news</code>. By the end you have a working, production-grade async application and a clear mental model of when and why to reach for each tool.</p>

<p>You should read this article if:</p>

<ul>
  <li>You understand the theory of <code class="language-plaintext highlighter-rouge">asyncio</code> but have not yet built something real with it.</li>
  <li>You want to see <code class="language-plaintext highlighter-rouge">TaskGroup</code>, <code class="language-plaintext highlighter-rouge">Semaphore</code>, and <code class="language-plaintext highlighter-rouge">asyncio.timeout</code> used together in a single project rather than in isolation.</li>
  <li>You are comfortable with Python but new to <code class="language-plaintext highlighter-rouge">aiohttp</code> and concurrent I/O patterns.</li>
</ul>

<p>This article is part of the <a href="/how-to-set-up-your-next-python-project/">Modern Python Application</a> series.</p>

<h2 id="the-project-an-async-news-aggregator">The Project: An Async News Aggregator</h2>

<p>The aggregator in this article is based on the <code class="language-plaintext highlighter-rouge">news.py</code> module from the open-source <a href="https://github.com/sasadangelo/alfred/blob/main/src/alfred/news.py">Alfred project</a> — a personal assistant that fetches daily news and delivers it via WhatsApp. The article walks through the same patterns the real module uses, introducing them one step at a time. The final section points out where the production code goes further.</p>

<p>The aggregator fetches headlines from five RSS sources — the same default feeds configured in Alfred:</p>

<table>
  <thead>
    <tr>
      <th>Source</th>
      <th>Category</th>
      <th>URL</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>BBC News</strong></td>
      <td>International</td>
      <td><code class="language-plaintext highlighter-rouge">https://feeds.bbci.co.uk/news/world/rss.xml</code></td>
    </tr>
    <tr>
      <td><strong>Repubblica</strong></td>
      <td>National</td>
      <td><code class="language-plaintext highlighter-rouge">https://www.repubblica.it/rss/homepage/rss2.0.xml</code></td>
    </tr>
    <tr>
      <td><strong>Wired</strong></td>
      <td>Technology</td>
      <td><code class="language-plaintext highlighter-rouge">https://www.wired.com/feed/rss</code></td>
    </tr>
    <tr>
      <td><strong>Sky Sport</strong></td>
      <td>Sport</td>
      <td><code class="language-plaintext highlighter-rouge">https://sport.sky.it/rss/sport.xml</code></td>
    </tr>
    <tr>
      <td><strong>Science Daily</strong></td>
      <td>Science</td>
      <td><code class="language-plaintext highlighter-rouge">https://www.sciencedaily.com/rss/top/science.xml</code></td>
    </tr>
  </tbody>
</table>

<p>The final output is a formatted text digest ready to be sent via WhatsApp, with each item showing title and a short summary stripped of HTML.</p>

<p>Install the dependencies first:</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip <span class="nb">install </span>aiohttp feedparser
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">aiohttp</code> — async HTTP client; see the <a href="https://docs.aiohttp.org/">aiohttp documentation</a> for the full API reference</li>
  <li><code class="language-plaintext highlighter-rouge">feedparser</code> — synchronous RSS/Atom/RDF parser; we feed it raw XML already in memory; see the <a href="https://feedparser.readthedocs.io/">feedparser documentation</a> for the full entry field reference</li>
</ul>

<h2 id="step-1--one-feed-one-coroutine">Step 1 — One Feed, One Coroutine</h2>

<p>Start with the simplest possible version: fetch a single RSS feed and parse it. The real Alfred module also strips HTML from each entry’s summary — let’s include that from the start, since it is a two-liner and avoids HTML noise in the output.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># news.py
</span><span class="kn">import</span> <span class="n">asyncio</span>
<span class="kn">import</span> <span class="n">re</span>

<span class="kn">import</span> <span class="n">aiohttp</span>
<span class="kn">import</span> <span class="n">feedparser</span>

<span class="n">TIMEOUT</span> <span class="o">=</span> <span class="n">aiohttp</span><span class="p">.</span><span class="nc">ClientTimeout</span><span class="p">(</span><span class="n">total</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
<span class="n">HEADERS</span> <span class="o">=</span> <span class="p">{</span><span class="sh">"</span><span class="s">User-Agent</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">Mozilla/5.0 (compatible; AlfredBot/1.0)</span><span class="sh">"</span><span class="p">}</span>
<span class="n">MAX_SUMMARY</span> <span class="o">=</span> <span class="mi">150</span>


<span class="k">def</span> <span class="nf">_strip_html</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
    <span class="sh">"""</span><span class="s">Remove HTML tags, collapse whitespace, and truncate to MAX_SUMMARY chars.</span><span class="sh">"""</span>
    <span class="n">clean</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nf">sub</span><span class="p">(</span><span class="sa">r</span><span class="sh">"</span><span class="s">\s+</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s"> </span><span class="sh">"</span><span class="p">,</span> <span class="n">re</span><span class="p">.</span><span class="nf">sub</span><span class="p">(</span><span class="sa">r</span><span class="sh">"</span><span class="s">&lt;[^&gt;]+&gt;</span><span class="sh">"</span><span class="p">,</span> <span class="sh">""</span><span class="p">,</span> <span class="n">text</span><span class="p">)).</span><span class="nf">strip</span><span class="p">()</span>
    <span class="k">return</span> <span class="n">clean</span><span class="p">[:</span><span class="n">MAX_SUMMARY</span><span class="p">]</span> <span class="o">+</span> <span class="sh">"</span><span class="s">…</span><span class="sh">"</span> <span class="k">if</span> <span class="nf">len</span><span class="p">(</span><span class="n">clean</span><span class="p">)</span> <span class="o">&gt;</span> <span class="n">MAX_SUMMARY</span> <span class="k">else</span> <span class="n">clean</span>


<span class="k">async</span> <span class="k">def</span> <span class="nf">fetch</span><span class="p">(</span><span class="n">session</span><span class="p">:</span> <span class="n">aiohttp</span><span class="p">.</span><span class="n">ClientSession</span><span class="p">,</span> <span class="n">url</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">3</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">]:</span>
    <span class="k">async</span> <span class="k">with</span> <span class="n">session</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="n">url</span><span class="p">)</span> <span class="k">as</span> <span class="n">resp</span><span class="p">:</span>
        <span class="n">resp</span><span class="p">.</span><span class="nf">raise_for_status</span><span class="p">()</span>
        <span class="n">text</span> <span class="o">=</span> <span class="k">await</span> <span class="n">resp</span><span class="p">.</span><span class="nf">text</span><span class="p">()</span>
    <span class="n">feed</span> <span class="o">=</span> <span class="n">feedparser</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
    <span class="k">return</span> <span class="p">[</span>
        <span class="p">{</span>
            <span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">:</span> <span class="n">e</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">,</span> <span class="sh">""</span><span class="p">).</span><span class="nf">strip</span><span class="p">(),</span>
            <span class="sh">"</span><span class="s">link</span><span class="sh">"</span><span class="p">:</span> <span class="n">e</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">link</span><span class="sh">"</span><span class="p">,</span> <span class="sh">""</span><span class="p">),</span>
            <span class="sh">"</span><span class="s">summary</span><span class="sh">"</span><span class="p">:</span> <span class="nf">_strip_html</span><span class="p">(</span><span class="n">e</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">summary</span><span class="sh">"</span><span class="p">,</span> <span class="sh">""</span><span class="p">)),</span>
        <span class="p">}</span>
        <span class="k">for</span> <span class="n">e</span> <span class="ow">in</span> <span class="n">feed</span><span class="p">.</span><span class="n">entries</span><span class="p">[:</span><span class="n">limit</span><span class="p">]</span>
    <span class="p">]</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">main</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="k">async</span> <span class="k">with</span> <span class="n">aiohttp</span><span class="p">.</span><span class="nc">ClientSession</span><span class="p">(</span><span class="n">timeout</span><span class="o">=</span><span class="n">TIMEOUT</span><span class="p">,</span> <span class="n">headers</span><span class="o">=</span><span class="n">HEADERS</span><span class="p">)</span> <span class="k">as</span> <span class="n">session</span><span class="p">:</span>
        <span class="n">items</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">fetch</span><span class="p">(</span><span class="n">session</span><span class="p">,</span> <span class="sh">"</span><span class="s">https://feeds.bbci.co.uk/news/world/rss.xml</span><span class="sh">"</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">items</span><span class="p">:</span>
        <span class="nf">print</span><span class="p">(</span><span class="n">item</span><span class="p">[</span><span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">])</span>
        <span class="nf">print</span><span class="p">(</span><span class="n">item</span><span class="p">[</span><span class="sh">"</span><span class="s">summary</span><span class="sh">"</span><span class="p">])</span>

<span class="n">asyncio</span><span class="p">.</span><span class="nf">run</span><span class="p">(</span><span class="nf">main</span><span class="p">())</span>
</code></pre></div></div>

<p>A few things worth noting:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">HEADERS</code> sets a <code class="language-plaintext highlighter-rouge">User-Agent</code> string. Many RSS servers silently return <code class="language-plaintext highlighter-rouge">403</code> for requests that look like bots without a recognisable user agent — this one-liner prevents that class of failure entirely.</li>
  <li><code class="language-plaintext highlighter-rouge">aiohttp.ClientSession</code> is created once and reused for all requests. Creating a session per request is the most common <code class="language-plaintext highlighter-rouge">aiohttp</code> mistake — it bypasses connection pooling entirely. One session per application is the rule.</li>
  <li><code class="language-plaintext highlighter-rouge">ClientTimeout(total=10)</code> sets a 10-second cap on the full operation. Without a timeout, a hanging feed hangs your entire program indefinitely.</li>
  <li><code class="language-plaintext highlighter-rouge">feedparser.parse(text)</code> is synchronous but runs in microseconds once the XML is already in memory — no need to offload it to a thread.</li>
  <li><code class="language-plaintext highlighter-rouge">resp.raise_for_status()</code> raises <code class="language-plaintext highlighter-rouge">aiohttp.ClientResponseError</code> for any 4xx or 5xx response immediately, rather than letting malformed data propagate silently.</li>
  <li><code class="language-plaintext highlighter-rouge">_strip_html()</code> removes HTML tags from the summary using two regex passes: first strips tags, then collapses whitespace. The truncation at <code class="language-plaintext highlighter-rouge">MAX_SUMMARY</code> chars keeps WhatsApp messages readable.</li>
</ul>

<blockquote>
  <p>A coroutine object does nothing until it is awaited. <code class="language-plaintext highlighter-rouge">fetch(session, url)</code> returns a coroutine — the HTTP request only fires when the event loop reaches <code class="language-plaintext highlighter-rouge">await</code>.</p>
</blockquote>

<h2 id="step-2--multiple-feeds-with-asynciogather">Step 2 — Multiple Feeds with <code class="language-plaintext highlighter-rouge">asyncio.gather</code></h2>

<p>Fetching feeds one by one defeats the purpose of async. <code class="language-plaintext highlighter-rouge">asyncio.gather</code> runs multiple coroutines concurrently — all five requests are in-flight simultaneously, and the total wall time equals the slowest single response, not the sum.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">DEFAULT_FEEDS</span> <span class="o">=</span> <span class="p">{</span>
    <span class="sh">"</span><span class="s">BBC News</span><span class="sh">"</span><span class="p">:</span>      <span class="sh">"</span><span class="s">https://feeds.bbci.co.uk/news/world/rss.xml</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">Repubblica</span><span class="sh">"</span><span class="p">:</span>    <span class="sh">"</span><span class="s">https://www.repubblica.it/rss/homepage/rss2.0.xml</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">Wired</span><span class="sh">"</span><span class="p">:</span>         <span class="sh">"</span><span class="s">https://www.wired.com/feed/rss</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">Sky Sport</span><span class="sh">"</span><span class="p">:</span>     <span class="sh">"</span><span class="s">https://sport.sky.it/rss/sport.xml</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">Science Daily</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">https://www.sciencedaily.com/rss/top/science.xml</span><span class="sh">"</span><span class="p">,</span>
<span class="p">}</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">main</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="k">async</span> <span class="k">with</span> <span class="n">aiohttp</span><span class="p">.</span><span class="nc">ClientSession</span><span class="p">(</span><span class="n">timeout</span><span class="o">=</span><span class="n">TIMEOUT</span><span class="p">,</span> <span class="n">headers</span><span class="o">=</span><span class="n">HEADERS</span><span class="p">)</span> <span class="k">as</span> <span class="n">session</span><span class="p">:</span>
        <span class="n">results</span> <span class="o">=</span> <span class="k">await</span> <span class="n">asyncio</span><span class="p">.</span><span class="nf">gather</span><span class="p">(</span>
            <span class="o">*</span><span class="p">[</span><span class="nf">fetch</span><span class="p">(</span><span class="n">session</span><span class="p">,</span> <span class="n">url</span><span class="p">)</span> <span class="k">for</span> <span class="n">url</span> <span class="ow">in</span> <span class="n">DEFAULT_FEEDS</span><span class="p">.</span><span class="nf">values</span><span class="p">()],</span>
            <span class="n">return_exceptions</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
        <span class="p">)</span>
    <span class="k">for</span> <span class="n">source</span><span class="p">,</span> <span class="n">result</span> <span class="ow">in</span> <span class="nf">zip</span><span class="p">(</span><span class="n">DEFAULT_FEEDS</span><span class="p">.</span><span class="nf">keys</span><span class="p">(),</span> <span class="n">results</span><span class="p">):</span>
        <span class="k">if</span> <span class="nf">isinstance</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="nb">Exception</span><span class="p">):</span>
            <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">source</span><span class="si">}</span><span class="s">: error — </span><span class="si">{</span><span class="n">result</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">result</span><span class="p">:</span>
                <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">[</span><span class="si">{</span><span class="n">source</span><span class="si">}</span><span class="s">] </span><span class="si">{</span><span class="n">item</span><span class="p">[</span><span class="sh">'</span><span class="s">title</span><span class="sh">'</span><span class="p">]</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">return_exceptions=True</code> is the key choice here. Without it, the first failed feed raises immediately and you lose all results. With it, exceptions are returned as values alongside successful results — you check <code class="language-plaintext highlighter-rouge">isinstance(result, Exception)</code> and continue. This is the right default for an aggregator where partial results are genuinely useful.</p>

<p>The limitation: if one feed fails, the others keep running regardless. There is no automatic cancellation. That is sometimes what you want (independent sources) and sometimes not (a batch of dependent requests where partial data is useless). The next section shows the alternative.</p>

<h2 id="step-3--structured-concurrency-with-taskgroup">Step 3 — Structured Concurrency with <code class="language-plaintext highlighter-rouge">TaskGroup</code></h2>

<p><code class="language-plaintext highlighter-rouge">asyncio.TaskGroup</code> (Python 3.11+, see the <a href="https://docs.python.org/3/library/asyncio-task.html#asyncio.TaskGroup">official docs</a>) enforces a stricter contract: if any task raises an unhandled exception, all siblings are cancelled and an <code class="language-plaintext highlighter-rouge">ExceptionGroup</code> is raised at the <code class="language-plaintext highlighter-rouge">async with</code> exit. Think of it as <code class="language-plaintext highlighter-rouge">gather</code> with a safety net <em>(here, that safety net never triggers — <code class="language-plaintext highlighter-rouge">fetch_safe</code> absorbs every exception internally before <code class="language-plaintext highlighter-rouge">TaskGroup</code> can see it)</em>.</p>

<p><img src="/assets/img/async-and-event-loop-in-python-asyncio-in-practice-taskgroup-isolation.png" alt="fetch_safe() isolates each feed failure — only the failing feed goes to the failed dict, siblings continue" width="760" height="400" class="responsive_img" /></p>

<p>For the aggregator, feeds are independent sources — a failure in one should not kill the others. The right pattern here is to absorb errors inside <code class="language-plaintext highlighter-rouge">fetch_safe</code> and return them as a third tuple element, then let <code class="language-plaintext highlighter-rouge">TaskGroup</code> manage the concurrency cleanly:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">def</span> <span class="nf">fetch_safe</span><span class="p">(</span>
    <span class="n">session</span><span class="p">:</span> <span class="n">aiohttp</span><span class="p">.</span><span class="n">ClientSession</span><span class="p">,</span>
    <span class="n">source</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
    <span class="n">url</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
    <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">3</span><span class="p">,</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">],</span> <span class="nb">Exception</span> <span class="o">|</span> <span class="bp">None</span><span class="p">]:</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="n">items</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">fetch</span><span class="p">(</span><span class="n">session</span><span class="p">,</span> <span class="n">url</span><span class="p">,</span> <span class="n">limit</span><span class="o">=</span><span class="n">limit</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">source</span><span class="p">,</span> <span class="n">items</span><span class="p">,</span> <span class="bp">None</span>
    <span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">exc</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">source</span><span class="p">,</span> <span class="p">[],</span> <span class="n">exc</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">main</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="n">results</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">]]</span> <span class="o">=</span> <span class="p">{}</span>
    <span class="n">failed</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">Exception</span><span class="p">]</span> <span class="o">=</span> <span class="p">{}</span>

    <span class="k">async</span> <span class="k">with</span> <span class="n">aiohttp</span><span class="p">.</span><span class="nc">ClientSession</span><span class="p">(</span><span class="n">timeout</span><span class="o">=</span><span class="n">TIMEOUT</span><span class="p">,</span> <span class="n">headers</span><span class="o">=</span><span class="n">HEADERS</span><span class="p">)</span> <span class="k">as</span> <span class="n">session</span><span class="p">:</span>
        <span class="k">async</span> <span class="k">with</span> <span class="n">asyncio</span><span class="p">.</span><span class="nc">TaskGroup</span><span class="p">()</span> <span class="k">as</span> <span class="n">tg</span><span class="p">:</span>
            <span class="n">tasks</span> <span class="o">=</span> <span class="p">{</span>
                <span class="n">source</span><span class="p">:</span> <span class="n">tg</span><span class="p">.</span><span class="nf">create_task</span><span class="p">(</span><span class="nf">fetch_safe</span><span class="p">(</span><span class="n">session</span><span class="p">,</span> <span class="n">source</span><span class="p">,</span> <span class="n">url</span><span class="p">))</span>
                <span class="k">for</span> <span class="n">source</span><span class="p">,</span> <span class="n">url</span> <span class="ow">in</span> <span class="n">DEFAULT_FEEDS</span><span class="p">.</span><span class="nf">items</span><span class="p">()</span>
            <span class="p">}</span>

    <span class="k">for</span> <span class="n">source</span><span class="p">,</span> <span class="n">task</span> <span class="ow">in</span> <span class="n">tasks</span><span class="p">.</span><span class="nf">items</span><span class="p">():</span>
        <span class="n">_</span><span class="p">,</span> <span class="n">items</span><span class="p">,</span> <span class="n">error</span> <span class="o">=</span> <span class="n">task</span><span class="p">.</span><span class="nf">result</span><span class="p">()</span>
        <span class="k">if</span> <span class="n">error</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
            <span class="n">failed</span><span class="p">[</span><span class="n">source</span><span class="p">]</span> <span class="o">=</span> <span class="n">error</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="n">results</span><span class="p">[</span><span class="n">source</span><span class="p">]</span> <span class="o">=</span> <span class="n">items</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">tg.create_task()</code> schedules each coroutine as a <code class="language-plaintext highlighter-rouge">Task</code> immediately. The <code class="language-plaintext highlighter-rouge">async with</code> block does not exit until all tasks are complete — no manual <code class="language-plaintext highlighter-rouge">await</code> for each one. The tasks dictionary gives you named access to each result after the group closes.</p>

<p>Notice that <code class="language-plaintext highlighter-rouge">fetch_safe</code> now returns a 3-tuple <code class="language-plaintext highlighter-rouge">(source, items, error | None)</code> instead of mixing exceptions and data in a single list. The error travels alongside the result as a first-class field — the caller unpacks it cleanly with <code class="language-plaintext highlighter-rouge">_, items, error = task.result()</code> and routes failures to a separate <code class="language-plaintext highlighter-rouge">failed</code> dict without any <code class="language-plaintext highlighter-rouge">isinstance</code> check.</p>

<blockquote>
  <p>Use <code class="language-plaintext highlighter-rouge">TaskGroup</code> when you want structured lifetimes for a set of concurrent tasks. Use <code class="language-plaintext highlighter-rouge">gather(return_exceptions=True)</code> when you need to collect all results including errors as data.</p>
</blockquote>

<p>As <a href="https://hynek.me/articles/waiting-in-asyncio/">Hynek Schlawack notes</a>, <code class="language-plaintext highlighter-rouge">TaskGroup</code> is now the recommended default for scheduling a nested group of tasks — it has fewer sharp edges than <code class="language-plaintext highlighter-rouge">gather</code> and its cancellation semantics are unambiguous.</p>

<h2 id="step-4--per-request-timeouts-with-asynciotimeout">Step 4 — Per-Request Timeouts with <code class="language-plaintext highlighter-rouge">asyncio.timeout</code></h2>

<p>The global <code class="language-plaintext highlighter-rouge">ClientTimeout</code> covers the session default, but individual feeds can be slower or faster. <code class="language-plaintext highlighter-rouge">asyncio.timeout</code> (Python 3.11+) wraps any block of async code with a deadline — if the block takes longer than <code class="language-plaintext highlighter-rouge">n</code> seconds, it raises <code class="language-plaintext highlighter-rouge">TimeoutError</code> and cancels the inner task cleanly.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">def</span> <span class="nf">fetch_safe</span><span class="p">(</span>
    <span class="n">session</span><span class="p">:</span> <span class="n">aiohttp</span><span class="p">.</span><span class="n">ClientSession</span><span class="p">,</span>
    <span class="n">source</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
    <span class="n">url</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
    <span class="n">timeout</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">8.0</span><span class="p">,</span>
    <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">3</span><span class="p">,</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">],</span> <span class="nb">Exception</span> <span class="o">|</span> <span class="bp">None</span><span class="p">]:</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="k">async</span> <span class="k">with</span> <span class="n">asyncio</span><span class="p">.</span><span class="nf">timeout</span><span class="p">(</span><span class="n">timeout</span><span class="p">):</span>
            <span class="n">items</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">fetch</span><span class="p">(</span><span class="n">session</span><span class="p">,</span> <span class="n">url</span><span class="p">,</span> <span class="n">limit</span><span class="o">=</span><span class="n">limit</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">source</span><span class="p">,</span> <span class="n">items</span><span class="p">,</span> <span class="bp">None</span>
    <span class="k">except</span> <span class="nb">TimeoutError</span> <span class="k">as</span> <span class="n">exc</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">source</span><span class="p">,</span> <span class="p">[],</span> <span class="n">exc</span>
    <span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">exc</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">source</span><span class="p">,</span> <span class="p">[],</span> <span class="n">exc</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">asyncio.timeout</code> is more composable than <code class="language-plaintext highlighter-rouge">asyncio.wait_for</code> — it is an async context manager that wraps a block rather than a single coroutine call. This means you can cover multiple <code class="language-plaintext highlighter-rouge">await</code> expressions with a single deadline, which is useful when a feed requires a follow-up request to resolve redirects.</p>

<p>One important rule: <code class="language-plaintext highlighter-rouge">TimeoutError</code> must be caught <em>outside</em> the <code class="language-plaintext highlighter-rouge">async with asyncio.timeout(...)</code> block, not inside it. The context manager converts the internal <code class="language-plaintext highlighter-rouge">CancelledError</code> into <code class="language-plaintext highlighter-rouge">TimeoutError</code> at its exit — catching it inside would swallow it before the conversion.</p>

<h2 id="step-5--rate-limiting-with-asynciosemaphore">Step 5 — Rate Limiting with <code class="language-plaintext highlighter-rouge">asyncio.Semaphore</code></h2>

<p>Five feeds is manageable, but extend this to 50 sources and you risk triggering rate limits or overwhelming slow servers with a burst of simultaneous connections. <code class="language-plaintext highlighter-rouge">asyncio.Semaphore</code> is a counter that limits how many coroutines can be inside a critical section at the same time.</p>

<p><img src="/assets/img/async-and-event-loop-in-python-asyncio-in-practice-semaphore-rate-limit.png" alt="asyncio.Semaphore(3) — rate-limiting concurrent requests to BBC News, Repubblica, Wired, Sky Sport, Science Daily" width="760" height="400" class="responsive_img" /></p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">def</span> <span class="nf">fetch_safe</span><span class="p">(</span>
    <span class="n">session</span><span class="p">:</span> <span class="n">aiohttp</span><span class="p">.</span><span class="n">ClientSession</span><span class="p">,</span>
    <span class="n">source</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
    <span class="n">url</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
    <span class="n">semaphore</span><span class="p">:</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">Semaphore</span><span class="p">,</span>
    <span class="n">timeout</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">8.0</span><span class="p">,</span>
    <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">3</span><span class="p">,</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">],</span> <span class="nb">Exception</span> <span class="o">|</span> <span class="bp">None</span><span class="p">]:</span>
    <span class="k">async</span> <span class="k">with</span> <span class="n">semaphore</span><span class="p">:</span>  <span class="c1"># acquire slot; blocks if all slots are taken
</span>        <span class="k">try</span><span class="p">:</span>
            <span class="k">async</span> <span class="k">with</span> <span class="n">asyncio</span><span class="p">.</span><span class="nf">timeout</span><span class="p">(</span><span class="n">timeout</span><span class="p">):</span>
                <span class="n">items</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">fetch</span><span class="p">(</span><span class="n">session</span><span class="p">,</span> <span class="n">url</span><span class="p">,</span> <span class="n">limit</span><span class="o">=</span><span class="n">limit</span><span class="p">)</span>
            <span class="k">return</span> <span class="n">source</span><span class="p">,</span> <span class="n">items</span><span class="p">,</span> <span class="bp">None</span>
        <span class="k">except</span> <span class="nb">TimeoutError</span> <span class="k">as</span> <span class="n">exc</span><span class="p">:</span>
            <span class="k">return</span> <span class="n">source</span><span class="p">,</span> <span class="p">[],</span> <span class="n">exc</span>
        <span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">exc</span><span class="p">:</span>
            <span class="k">return</span> <span class="n">source</span><span class="p">,</span> <span class="p">[],</span> <span class="n">exc</span>


<span class="k">async</span> <span class="k">def</span> <span class="nf">_fetch_all</span><span class="p">(</span>
    <span class="n">feeds</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">],</span> <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">3</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">]],</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">Exception</span><span class="p">]]:</span>
    <span class="n">semaphore</span> <span class="o">=</span> <span class="n">asyncio</span><span class="p">.</span><span class="nc">Semaphore</span><span class="p">(</span><span class="mi">3</span><span class="p">)</span>
    <span class="n">results</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">]]</span> <span class="o">=</span> <span class="p">{}</span>
    <span class="n">failed</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">Exception</span><span class="p">]</span> <span class="o">=</span> <span class="p">{}</span>

    <span class="k">async</span> <span class="nf">with </span><span class="p">(</span>
        <span class="n">aiohttp</span><span class="p">.</span><span class="nc">ClientSession</span><span class="p">(</span><span class="n">timeout</span><span class="o">=</span><span class="n">TIMEOUT</span><span class="p">,</span> <span class="n">headers</span><span class="o">=</span><span class="n">HEADERS</span><span class="p">)</span> <span class="k">as</span> <span class="n">session</span><span class="p">,</span>
        <span class="n">asyncio</span><span class="p">.</span><span class="nc">TaskGroup</span><span class="p">()</span> <span class="k">as</span> <span class="n">tg</span><span class="p">,</span>
    <span class="p">):</span>
        <span class="n">tasks</span> <span class="o">=</span> <span class="p">{</span>
            <span class="n">source</span><span class="p">:</span> <span class="n">tg</span><span class="p">.</span><span class="nf">create_task</span><span class="p">(</span><span class="nf">fetch_safe</span><span class="p">(</span><span class="n">session</span><span class="p">,</span> <span class="n">source</span><span class="p">,</span> <span class="n">url</span><span class="p">,</span> <span class="n">semaphore</span><span class="p">,</span> <span class="n">limit</span><span class="o">=</span><span class="n">limit</span><span class="p">))</span>
            <span class="k">for</span> <span class="n">source</span><span class="p">,</span> <span class="n">url</span> <span class="ow">in</span> <span class="n">feeds</span><span class="p">.</span><span class="nf">items</span><span class="p">()</span>
        <span class="p">}</span>

    <span class="k">for</span> <span class="n">source</span><span class="p">,</span> <span class="n">task</span> <span class="ow">in</span> <span class="n">tasks</span><span class="p">.</span><span class="nf">items</span><span class="p">():</span>
        <span class="n">_</span><span class="p">,</span> <span class="n">items</span><span class="p">,</span> <span class="n">error</span> <span class="o">=</span> <span class="n">task</span><span class="p">.</span><span class="nf">result</span><span class="p">()</span>
        <span class="k">if</span> <span class="n">error</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
            <span class="n">failed</span><span class="p">[</span><span class="n">source</span><span class="p">]</span> <span class="o">=</span> <span class="n">error</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="n">results</span><span class="p">[</span><span class="n">source</span><span class="p">]</span> <span class="o">=</span> <span class="n">items</span>

    <span class="k">return</span> <span class="n">results</span><span class="p">,</span> <span class="n">failed</span>
</code></pre></div></div>

<p>The semaphore wraps the entire request block including the timeout. When three tasks are already inside, the fourth <code class="language-plaintext highlighter-rouge">async with semaphore:</code> suspends at <code class="language-plaintext highlighter-rouge">acquire()</code> — the event loop runs other work while it waits. As soon as one of the active tasks exits the block, the semaphore releases a slot and the waiting task resumes.</p>

<p>The <code class="language-plaintext highlighter-rouge">_fetch_all</code> helper wraps everything: it creates the semaphore, opens the session and the <code class="language-plaintext highlighter-rouge">TaskGroup</code> together using a parenthesised <code class="language-plaintext highlighter-rouge">async with</code> block (Python 3.10+), spawns one task per feed, then collects results into <code class="language-plaintext highlighter-rouge">results</code> and <code class="language-plaintext highlighter-rouge">failed</code> dicts. Returning both separately is cleaner than mixing exceptions and data in a single list — the caller sees clean items and can act on errors independently.</p>

<blockquote>
  <p><code class="language-plaintext highlighter-rouge">asyncio.Semaphore</code> is not thread-safe. Use it only inside a single event loop — never across threads.</p>
</blockquote>

<h2 id="step-6--formatting-the-digest-with-format_news">Step 6 — Formatting the Digest with <code class="language-plaintext highlighter-rouge">format_news</code></h2>

<p>All the data is now in <code class="language-plaintext highlighter-rouge">results</code>. The last step before delivery is turning it into a human-readable string — one that WhatsApp can render with bold headers and bullet points using its own Markdown-like syntax.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">format_news</span><span class="p">(</span><span class="n">results</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">]])</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
    <span class="n">blocks</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">source</span><span class="p">,</span> <span class="n">items</span> <span class="ow">in</span> <span class="n">results</span><span class="p">.</span><span class="nf">items</span><span class="p">():</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="n">items</span><span class="p">:</span>
            <span class="k">continue</span>
        <span class="n">lines</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span><span class="sa">f</span><span class="sh">"</span><span class="s">*</span><span class="si">{</span><span class="n">source</span><span class="si">}</span><span class="s">*</span><span class="sh">"</span><span class="p">]</span>
        <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">items</span><span class="p">:</span>
            <span class="n">lines</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">• </span><span class="si">{</span><span class="n">item</span><span class="p">[</span><span class="sh">'</span><span class="s">title</span><span class="sh">'</span><span class="p">]</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
            <span class="k">if</span> <span class="n">item</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">summary</span><span class="sh">"</span><span class="p">):</span>
                <span class="n">lines</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">  </span><span class="si">{</span><span class="n">item</span><span class="p">[</span><span class="sh">'</span><span class="s">summary</span><span class="sh">'</span><span class="p">]</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
        <span class="n">blocks</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="sh">"</span><span class="se">\n</span><span class="sh">"</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">lines</span><span class="p">))</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">blocks</span><span class="p">:</span>
        <span class="k">return</span> <span class="sh">""</span>
    <span class="k">return</span> <span class="sh">"</span><span class="s">📰 *Ultime Notizie*</span><span class="se">\n\n</span><span class="sh">"</span> <span class="o">+</span> <span class="sh">"</span><span class="se">\n\n</span><span class="sh">"</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">blocks</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">format_news</code> is a plain synchronous function — no <code class="language-plaintext highlighter-rouge">async</code>, no <code class="language-plaintext highlighter-rouge">await</code>. By the time it runs, all the I/O is done and <code class="language-plaintext highlighter-rouge">results</code> is already in memory. This is the right pattern: keep async code to the network and disk boundary, and let everything else be normal Python.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">*source*</code> produces bold text in WhatsApp.</li>
  <li><code class="language-plaintext highlighter-rouge">• item['title']</code> is a bullet item.</li>
  <li>The summary line is indented with two spaces and shown only when non-empty.</li>
  <li>Sources with zero items are skipped entirely.</li>
</ul>

<h2 id="the-complete-aggregator">The Complete Aggregator</h2>

<p>Putting all the pieces together — this is the full <a href="https://github.com/sasadangelo/alfred/blob/main/src/alfred/news.py"><code class="language-plaintext highlighter-rouge">news.py</code></a> module from Alfred, with <code class="language-plaintext highlighter-rouge">DEFAULT_FEEDS</code> hardcoded for readability:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># news.py
</span><span class="kn">import</span> <span class="n">asyncio</span>
<span class="kn">import</span> <span class="n">re</span>

<span class="kn">import</span> <span class="n">aiohttp</span>
<span class="kn">import</span> <span class="n">feedparser</span>

<span class="n">TIMEOUT</span> <span class="o">=</span> <span class="n">aiohttp</span><span class="p">.</span><span class="nc">ClientTimeout</span><span class="p">(</span><span class="n">total</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
<span class="n">HEADERS</span> <span class="o">=</span> <span class="p">{</span><span class="sh">"</span><span class="s">User-Agent</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">Mozilla/5.0 (compatible; AlfredBot/1.0)</span><span class="sh">"</span><span class="p">}</span>
<span class="n">MAX_SUMMARY</span> <span class="o">=</span> <span class="mi">150</span>

<span class="n">DEFAULT_FEEDS</span> <span class="o">=</span> <span class="p">{</span>
    <span class="sh">"</span><span class="s">BBC News</span><span class="sh">"</span><span class="p">:</span>      <span class="sh">"</span><span class="s">https://feeds.bbci.co.uk/news/world/rss.xml</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">Repubblica</span><span class="sh">"</span><span class="p">:</span>    <span class="sh">"</span><span class="s">https://www.repubblica.it/rss/homepage/rss2.0.xml</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">Wired</span><span class="sh">"</span><span class="p">:</span>         <span class="sh">"</span><span class="s">https://www.wired.com/feed/rss</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">Sky Sport</span><span class="sh">"</span><span class="p">:</span>     <span class="sh">"</span><span class="s">https://sport.sky.it/rss/sport.xml</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">Science Daily</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">https://www.sciencedaily.com/rss/top/science.xml</span><span class="sh">"</span><span class="p">,</span>
<span class="p">}</span>


<span class="k">def</span> <span class="nf">_strip_html</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
    <span class="n">clean</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nf">sub</span><span class="p">(</span><span class="sa">r</span><span class="sh">"</span><span class="s">\s+</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s"> </span><span class="sh">"</span><span class="p">,</span> <span class="n">re</span><span class="p">.</span><span class="nf">sub</span><span class="p">(</span><span class="sa">r</span><span class="sh">"</span><span class="s">&lt;[^&gt;]+&gt;</span><span class="sh">"</span><span class="p">,</span> <span class="sh">""</span><span class="p">,</span> <span class="n">text</span><span class="p">)).</span><span class="nf">strip</span><span class="p">()</span>
    <span class="k">return</span> <span class="n">clean</span><span class="p">[:</span><span class="n">MAX_SUMMARY</span><span class="p">]</span> <span class="o">+</span> <span class="sh">"</span><span class="s">…</span><span class="sh">"</span> <span class="k">if</span> <span class="nf">len</span><span class="p">(</span><span class="n">clean</span><span class="p">)</span> <span class="o">&gt;</span> <span class="n">MAX_SUMMARY</span> <span class="k">else</span> <span class="n">clean</span>


<span class="k">async</span> <span class="k">def</span> <span class="nf">fetch</span><span class="p">(</span><span class="n">session</span><span class="p">:</span> <span class="n">aiohttp</span><span class="p">.</span><span class="n">ClientSession</span><span class="p">,</span> <span class="n">url</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">3</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">]:</span>
    <span class="k">async</span> <span class="k">with</span> <span class="n">session</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="n">url</span><span class="p">)</span> <span class="k">as</span> <span class="n">resp</span><span class="p">:</span>
        <span class="n">resp</span><span class="p">.</span><span class="nf">raise_for_status</span><span class="p">()</span>
        <span class="n">text</span> <span class="o">=</span> <span class="k">await</span> <span class="n">resp</span><span class="p">.</span><span class="nf">text</span><span class="p">()</span>
    <span class="n">feed</span> <span class="o">=</span> <span class="n">feedparser</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
    <span class="k">return</span> <span class="p">[</span>
        <span class="p">{</span>
            <span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">:</span> <span class="n">e</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">,</span> <span class="sh">""</span><span class="p">).</span><span class="nf">strip</span><span class="p">(),</span>
            <span class="sh">"</span><span class="s">link</span><span class="sh">"</span><span class="p">:</span> <span class="n">e</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">link</span><span class="sh">"</span><span class="p">,</span> <span class="sh">""</span><span class="p">),</span>
            <span class="sh">"</span><span class="s">summary</span><span class="sh">"</span><span class="p">:</span> <span class="nf">_strip_html</span><span class="p">(</span><span class="n">e</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">summary</span><span class="sh">"</span><span class="p">,</span> <span class="sh">""</span><span class="p">)),</span>
        <span class="p">}</span>
        <span class="k">for</span> <span class="n">e</span> <span class="ow">in</span> <span class="n">feed</span><span class="p">.</span><span class="n">entries</span><span class="p">[:</span><span class="n">limit</span><span class="p">]</span>
    <span class="p">]</span>


<span class="k">async</span> <span class="k">def</span> <span class="nf">fetch_safe</span><span class="p">(</span>
    <span class="n">session</span><span class="p">:</span> <span class="n">aiohttp</span><span class="p">.</span><span class="n">ClientSession</span><span class="p">,</span>
    <span class="n">source</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
    <span class="n">url</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span>
    <span class="n">semaphore</span><span class="p">:</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">Semaphore</span><span class="p">,</span>
    <span class="n">timeout</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">8.0</span><span class="p">,</span>
    <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">3</span><span class="p">,</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">],</span> <span class="nb">Exception</span> <span class="o">|</span> <span class="bp">None</span><span class="p">]:</span>
    <span class="k">async</span> <span class="k">with</span> <span class="n">semaphore</span><span class="p">:</span>
        <span class="k">try</span><span class="p">:</span>
            <span class="k">async</span> <span class="k">with</span> <span class="n">asyncio</span><span class="p">.</span><span class="nf">timeout</span><span class="p">(</span><span class="n">timeout</span><span class="p">):</span>
                <span class="n">items</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">fetch</span><span class="p">(</span><span class="n">session</span><span class="p">,</span> <span class="n">url</span><span class="p">,</span> <span class="n">limit</span><span class="o">=</span><span class="n">limit</span><span class="p">)</span>
            <span class="k">return</span> <span class="n">source</span><span class="p">,</span> <span class="n">items</span><span class="p">,</span> <span class="bp">None</span>
        <span class="k">except</span> <span class="nb">TimeoutError</span> <span class="k">as</span> <span class="n">exc</span><span class="p">:</span>
            <span class="k">return</span> <span class="n">source</span><span class="p">,</span> <span class="p">[],</span> <span class="n">exc</span>
        <span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">exc</span><span class="p">:</span>
            <span class="k">return</span> <span class="n">source</span><span class="p">,</span> <span class="p">[],</span> <span class="n">exc</span>


<span class="k">async</span> <span class="k">def</span> <span class="nf">_fetch_all</span><span class="p">(</span>
    <span class="n">feeds</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">],</span> <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">3</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">]],</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">Exception</span><span class="p">]]:</span>
    <span class="n">semaphore</span> <span class="o">=</span> <span class="n">asyncio</span><span class="p">.</span><span class="nc">Semaphore</span><span class="p">(</span><span class="mi">3</span><span class="p">)</span>
    <span class="n">results</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">]]</span> <span class="o">=</span> <span class="p">{}</span>
    <span class="n">failed</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">Exception</span><span class="p">]</span> <span class="o">=</span> <span class="p">{}</span>

    <span class="k">async</span> <span class="nf">with </span><span class="p">(</span>
        <span class="n">aiohttp</span><span class="p">.</span><span class="nc">ClientSession</span><span class="p">(</span><span class="n">timeout</span><span class="o">=</span><span class="n">TIMEOUT</span><span class="p">,</span> <span class="n">headers</span><span class="o">=</span><span class="n">HEADERS</span><span class="p">)</span> <span class="k">as</span> <span class="n">session</span><span class="p">,</span>
        <span class="n">asyncio</span><span class="p">.</span><span class="nc">TaskGroup</span><span class="p">()</span> <span class="k">as</span> <span class="n">tg</span><span class="p">,</span>
    <span class="p">):</span>
        <span class="n">tasks</span> <span class="o">=</span> <span class="p">{</span>
            <span class="n">source</span><span class="p">:</span> <span class="n">tg</span><span class="p">.</span><span class="nf">create_task</span><span class="p">(</span><span class="nf">fetch_safe</span><span class="p">(</span><span class="n">session</span><span class="p">,</span> <span class="n">source</span><span class="p">,</span> <span class="n">url</span><span class="p">,</span> <span class="n">semaphore</span><span class="p">,</span> <span class="n">limit</span><span class="o">=</span><span class="n">limit</span><span class="p">))</span>
            <span class="k">for</span> <span class="n">source</span><span class="p">,</span> <span class="n">url</span> <span class="ow">in</span> <span class="n">feeds</span><span class="p">.</span><span class="nf">items</span><span class="p">()</span>
        <span class="p">}</span>

    <span class="k">for</span> <span class="n">source</span><span class="p">,</span> <span class="n">task</span> <span class="ow">in</span> <span class="n">tasks</span><span class="p">.</span><span class="nf">items</span><span class="p">():</span>
        <span class="n">_</span><span class="p">,</span> <span class="n">items</span><span class="p">,</span> <span class="n">error</span> <span class="o">=</span> <span class="n">task</span><span class="p">.</span><span class="nf">result</span><span class="p">()</span>
        <span class="k">if</span> <span class="n">error</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
            <span class="n">failed</span><span class="p">[</span><span class="n">source</span><span class="p">]</span> <span class="o">=</span> <span class="n">error</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="n">results</span><span class="p">[</span><span class="n">source</span><span class="p">]</span> <span class="o">=</span> <span class="n">items</span>

    <span class="k">return</span> <span class="n">results</span><span class="p">,</span> <span class="n">failed</span>


<span class="k">def</span> <span class="nf">format_news</span><span class="p">(</span><span class="n">results</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">]])</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
    <span class="n">blocks</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">source</span><span class="p">,</span> <span class="n">items</span> <span class="ow">in</span> <span class="n">results</span><span class="p">.</span><span class="nf">items</span><span class="p">():</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="n">items</span><span class="p">:</span>
            <span class="k">continue</span>
        <span class="n">lines</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span><span class="sa">f</span><span class="sh">"</span><span class="s">*</span><span class="si">{</span><span class="n">source</span><span class="si">}</span><span class="s">*</span><span class="sh">"</span><span class="p">]</span>
        <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">items</span><span class="p">:</span>
            <span class="n">lines</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">• </span><span class="si">{</span><span class="n">item</span><span class="p">[</span><span class="sh">'</span><span class="s">title</span><span class="sh">'</span><span class="p">]</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
            <span class="k">if</span> <span class="n">item</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">summary</span><span class="sh">"</span><span class="p">):</span>
                <span class="n">lines</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">  </span><span class="si">{</span><span class="n">item</span><span class="p">[</span><span class="sh">'</span><span class="s">summary</span><span class="sh">'</span><span class="p">]</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
        <span class="n">blocks</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="sh">"</span><span class="se">\n</span><span class="sh">"</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">lines</span><span class="p">))</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">blocks</span><span class="p">:</span>
        <span class="k">return</span> <span class="sh">""</span>
    <span class="k">return</span> <span class="sh">"</span><span class="s">📰 *Ultime Notizie*</span><span class="se">\n\n</span><span class="sh">"</span> <span class="o">+</span> <span class="sh">"</span><span class="se">\n\n</span><span class="sh">"</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">blocks</span><span class="p">)</span>


<span class="k">async</span> <span class="k">def</span> <span class="nf">main</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="n">results</span><span class="p">,</span> <span class="n">failed</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">_fetch_all</span><span class="p">(</span><span class="n">DEFAULT_FEEDS</span><span class="p">)</span>

    <span class="nf">print</span><span class="p">(</span><span class="nf">format_news</span><span class="p">(</span><span class="n">results</span><span class="p">))</span>

    <span class="k">if</span> <span class="n">failed</span><span class="p">:</span>
        <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">--- Failed feeds ---</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">for</span> <span class="n">source</span><span class="p">,</span> <span class="n">error</span> <span class="ow">in</span> <span class="n">failed</span><span class="p">.</span><span class="nf">items</span><span class="p">():</span>
            <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">source</span><span class="si">}</span><span class="s">: </span><span class="si">{</span><span class="n">error</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>


<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="sh">"</span><span class="s">__main__</span><span class="sh">"</span><span class="p">:</span>
    <span class="n">asyncio</span><span class="p">.</span><span class="nf">run</span><span class="p">(</span><span class="nf">main</span><span class="p">())</span>
</code></pre></div></div>

<p>Run it:</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python news.py
</code></pre></div></div>

<p>The expected output looks like:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>📰 *Ultime Notizie*

*BBC News*
• Article title one
  Short summary of the article…
• Article title two
  Short summary of the article…

*Repubblica*
• ...
</code></pre></div></div>

<h2 id="how-the-event-loop-sees-this">How the Event Loop Sees This</h2>

<p>Every <code class="language-plaintext highlighter-rouge">await</code> in the code above is a yield point — the moment a coroutine suspends and hands control back to the event loop. The diagram below shows what happens with five concurrent <code class="language-plaintext highlighter-rouge">fetch(url)</code> calls: the event loop runs all five as tasks of the same coroutine function, each suspended at its own <code class="language-plaintext highlighter-rouge">await</code>, each holding a different URL.</p>

<p><img src="/assets/img/async-and-event-loop-in-python-asyncio-in-practice-event-loop-wheel.png" alt="The event loop running five concurrent fetch(url) coroutines — BBC News, Repubblica, Wired, Sky Sport, Science Daily" width="760" height="400" class="responsive_img" /></p>

<p>None of the feeds block each other. While <code class="language-plaintext highlighter-rouge">fetch(url)</code> for BBC News is waiting for bytes from the BBC server, the event loop resumes <code class="language-plaintext highlighter-rouge">fetch(url)</code> for Repubblica, and so on. The total wall time collapses to the latency of the single slowest feed — not the sum of all five.</p>

<blockquote>
  <p>The event loop does not make individual requests faster. It makes the waiting invisible by running other work while one request is in flight.</p>
</blockquote>

<h2 id="patterns-compared">Patterns Compared</h2>

<table>
  <thead>
    <tr>
      <th>Pattern</th>
      <th>API</th>
      <th>Best for</th>
      <th>Cancels siblings on failure?</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Sequential</strong></td>
      <td><code class="language-plaintext highlighter-rouge">await</code></td>
      <td>Dependent steps, no concurrency needed</td>
      <td>N/A</td>
    </tr>
    <tr>
      <td><strong>Concurrent, collect all</strong></td>
      <td><code class="language-plaintext highlighter-rouge">asyncio.gather(return_exceptions=True)</code></td>
      <td>Independent tasks, partial results useful</td>
      <td>No</td>
    </tr>
    <tr>
      <td><strong>Structured concurrency</strong></td>
      <td><code class="language-plaintext highlighter-rouge">asyncio.TaskGroup</code></td>
      <td>Independent tasks, clean lifetimes</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td><strong>Deadline</strong></td>
      <td><code class="language-plaintext highlighter-rouge">asyncio.timeout</code></td>
      <td>Any block that must not hang</td>
      <td>Yes (inner task)</td>
    </tr>
    <tr>
      <td><strong>Rate limit</strong></td>
      <td><code class="language-plaintext highlighter-rouge">asyncio.Semaphore</code></td>
      <td>Limiting concurrent access to a resource</td>
      <td>No</td>
    </tr>
    <tr>
      <td><strong>Sync formatting</strong></td>
      <td>plain <code class="language-plaintext highlighter-rouge">def</code></td>
      <td>Post-I/O data shaping, no blocking concerns</td>
      <td>N/A</td>
    </tr>
  </tbody>
</table>

<h2 id="conclusion">Conclusion</h2>

<p>In this article we covered:</p>

<ul>
  <li>Starting with a single <code class="language-plaintext highlighter-rouge">async def fetch()</code> coroutine, a shared <code class="language-plaintext highlighter-rouge">aiohttp.ClientSession</code>, and a <code class="language-plaintext highlighter-rouge">_strip_html()</code> helper to produce clean summaries from the first line.</li>
  <li>Adding a <code class="language-plaintext highlighter-rouge">User-Agent</code> header to avoid silent <code class="language-plaintext highlighter-rouge">403</code> rejections from RSS servers that block unrecognised clients.</li>
  <li>Fetching multiple feeds concurrently with <code class="language-plaintext highlighter-rouge">asyncio.gather(return_exceptions=True)</code> to collect partial results when some sources fail.</li>
  <li>Replacing <code class="language-plaintext highlighter-rouge">gather</code> with <code class="language-plaintext highlighter-rouge">asyncio.TaskGroup</code> for structured concurrency with clean task lifetimes, and switching <code class="language-plaintext highlighter-rouge">fetch_safe</code> to a 3-tuple return so errors travel as data rather than exceptions.</li>
  <li>Wrapping individual requests with <code class="language-plaintext highlighter-rouge">asyncio.timeout</code> to enforce per-source deadlines without blocking the event loop.</li>
  <li>Using <code class="language-plaintext highlighter-rouge">asyncio.Semaphore(3)</code> and a <code class="language-plaintext highlighter-rouge">_fetch_all</code> helper with a parenthesised <code class="language-plaintext highlighter-rouge">async with</code> block to cap concurrent connections and keep the call site clean.</li>
  <li>Converting the results dict to a WhatsApp-ready string with a plain synchronous <code class="language-plaintext highlighter-rouge">format_news</code> function — keeping async code at the I/O boundary and normal Python everywhere else.</li>
</ul>

<p>The <a href="/building-a-python-cli-with-the-command-pattern/">next article</a> starts a three-part mini-series on Python CLI development — building the same Task Manager CLI first with <code class="language-plaintext highlighter-rouge">argparse</code> and the Command Pattern, then with Click, then with Typer.</p>]]></content><author><name>Salvatore D&apos;Angelo</name></author><category term="Programming" /><category term="programming" /><category term="python" /><summary type="html"><![CDATA[Build a real async news aggregator with aiohttp step by step, adding one asyncio pattern at a time: gather, TaskGroup, timeouts, and Semaphore.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://code4projects.org/assets/img/async-and-event-loop-in-python-asyncio-in-practice-hero.jpg" /><media:content medium="image" url="https://code4projects.org/assets/img/async-and-event-loop-in-python-asyncio-in-practice-hero.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Concurrency in Python: Threads, Processes, and the Event Loop</title><link href="https://code4projects.org/concurrency-in-python-threads-processes-and-the-event-loop/" rel="alternate" type="text/html" title="Concurrency in Python: Threads, Processes, and the Event Loop" /><published>2026-09-06T00:00:00+00:00</published><updated>2026-09-06T00:00:00+00:00</updated><id>https://code4projects.org/concurrency-in-python-threads-processes-and-the-event-loop</id><content type="html" xml:base="https://code4projects.org/concurrency-in-python-threads-processes-and-the-event-loop/"><![CDATA[<p><img src="/assets/img/concurrency-in-python-threads-processes-and-the-event-loop-hero.webp" alt="Concurrency in Python: Threads, Processes, and the Event Loop" width="760" height="400" class="responsive_img" /></p>

<h2 id="introduction">Introduction</h2>

<p>Your Python application works fine until the moment it has to do more than one thing at a time. A web server blocking on every database query, a scraper waiting for each HTTP response before firing the next — the code is correct, it just does not scale. Python offers three concurrency models to fix this: <strong>threads</strong>, <strong>processes</strong>, and <strong>the event loop</strong>. Each solves a different problem, and choosing the wrong one costs you performance or correctness.</p>

<p>This article is part of the <a href="/how-to-set-up-your-next-python-project/">Modern Python Application</a> series.</p>

<p>You should read this article if:</p>

<ul>
  <li>You have hit a performance wall with sequential Python code and want to understand your options.</li>
  <li>You are confused about when threads help and when the GIL gets in the way.</li>
  <li>You want to understand why <code class="language-plaintext highlighter-rouge">asyncio</code> became the standard for high-concurrency services.</li>
</ul>

<h2 id="the-restaurant-metaphor">The Restaurant Metaphor</h2>

<p>Think of your Python program as a restaurant.</p>

<p><strong>Sequential model</strong> — one waiter takes an order, walks to the kitchen, and stands there until the dish is ready before returning. The cook (I/O, a DB query, a network call) does all the work while the waiter stands idle.</p>

<p><strong>Thread pool model</strong> — a dispatcher assigns each order to a pool waiter who walks to the kitchen and waits. Multiple orders are in-flight simultaneously. The catch: every waiter occupies memory even while idle, and coordinating handoffs costs time — the equivalent of context switching between threads. The more waiters, the higher the overhead. This model worked well for enterprise web apps when concurrency stayed in the hundreds.</p>

<p><strong>Event loop model</strong> — one efficient waiter drops an order at the kitchen pass and immediately moves to the next table. She never waits. Periodically she checks which dishes are ready and delivers them. If the restaurant can afford multiple waiters of this kind — one per CPU core — throughput scales proportionally, each running her own independent event loop. Node.js popularised this model in 2010; Python’s <code class="language-plaintext highlighter-rouge">asyncio</code> followed in 2014 and today underpins FastAPI, aiohttp, and every high-throughput Python service.</p>

<blockquote>
  <p>The event loop does not make individual operations faster — it makes the <em>waiting</em> disappear.</p>
</blockquote>

<h2 id="parallelism-vs-concurrency">Parallelism vs Concurrency</h2>

<p>Two terms that are often used interchangeably but mean different things.</p>

<p><strong>Parallelism</strong> — multiple tasks execute <em>at the same instant</em> on multiple CPU cores. Wall-clock time is divided by the number of cores.</p>

<p><strong>Concurrency</strong> — multiple tasks are <em>in progress</em> at the same time, but not necessarily executing simultaneously. A single core interleaves them: work on A, pause, switch to B, pause, switch to C. No two tasks run at the exact same instant, but all make progress.</p>

<p><img src="/assets/img/concurrency-in-python-parallelism-vs-concurrency.svg" alt="Parallelism vs Concurrency" width="760" height="400" class="responsive_img" /></p>

<blockquote>
  <p>Concurrency is about <em>dealing with</em> many things at once. Parallelism is about <em>doing</em> many things at once.</p>
</blockquote>

<p>The trade-off: concurrency hides <em>waiting</em> (I/O), parallelism reduces <em>computation</em> time. This maps directly onto Python’s three models:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">asyncio</code></strong> — concurrency on a single thread. Hides I/O waiting. Does not reduce computation time.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">ThreadPoolExecutor</code></strong> — concurrency across threads. Hides I/O waiting. Does not reduce computation time (GIL).</li>
  <li><strong><code class="language-plaintext highlighter-rouge">ProcessPoolExecutor</code></strong> — true parallelism. Reduces computation time for CPU-bound work.</li>
</ul>

<h2 id="threads-vs-processes">Threads vs Processes</h2>

<p>A thread and a process are both units of execution, but they differ in one fundamental way: <strong>what they share</strong>.</p>

<p>A <strong>thread</strong> lives inside a process. All threads in the same process share the same memory — the same heap, the same global variables, the same open file handles. Communication between threads is fast and free: one thread writes to a variable, another reads it. The downside is that this sharing requires coordination, and in CPython it means sharing the GIL.</p>

<p>A <strong>process</strong> is a fully isolated execution environment. It has its own memory space, its own Python interpreter, and its own GIL. Processes do not share memory — to pass data between them, objects must be serialised (pickled) and sent through a pipe or queue. The upside: with no shared GIL, multiple processes run Python bytecode in true parallel on separate cores.</p>

<p><img src="/assets/img/concurrency-in-python-threads-vs-processes.svg" alt="Threads vs Processes" width="760" height="400" class="responsive_img" /></p>

<p>The diagram shows why this matters for Python concurrency. Threads inside Process A share one GIL — only one runs at a time. Process B and Process C each have their own GIL and run simultaneously on separate cores. This is exactly why <code class="language-plaintext highlighter-rouge">ProcessPoolExecutor</code> bypasses the GIL while <code class="language-plaintext highlighter-rouge">ThreadPoolExecutor</code> does not.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th><strong>Threads</strong></th>
      <th><strong>Processes</strong></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Memory</strong></td>
      <td>Shared — no copy needed</td>
      <td>Isolated — data must be serialised</td>
    </tr>
    <tr>
      <td><strong>GIL</strong></td>
      <td>Shared — one at a time</td>
      <td>Independent — true parallelism</td>
    </tr>
    <tr>
      <td><strong>Startup cost</strong></td>
      <td>Low</td>
      <td>High (spawn a new interpreter)</td>
    </tr>
    <tr>
      <td><strong>Communication</strong></td>
      <td>Direct (shared variables)</td>
      <td>Via queues or pipes (pickling required)</td>
    </tr>
    <tr>
      <td><strong>Best for</strong></td>
      <td>I/O-bound concurrency</td>
      <td>CPU-bound parallelism</td>
    </tr>
  </tbody>
</table>

<h2 id="the-gil-why-threads-are-not-what-you-think">The GIL: Why Threads Are Not What You Think</h2>

<p>Python’s <code class="language-plaintext highlighter-rouge">threading</code> module exists and works, but there is a catch: the <strong>Global Interpreter Lock</strong> (GIL). The GIL is a mutex inside CPython that allows only one thread to execute Python bytecode at a time, regardless of how many CPU cores your machine has.</p>

<p>This is fundamentally different from Java or C#, where the JVM and CLR manage memory with a garbage collector that does not need reference counting — so there is no GIL. In those languages, four threads on a four-core machine execute bytecode in true parallel. If you have fewer cores than threads, you get concurrency (threads take turns); if you have as many cores as threads, you get genuine parallelism.</p>

<p>In CPython the picture is different.</p>

<p><img src="/assets/img/concurrency-in-python-java-vs-cpython-threads.svg" alt="Java vs CPython threading model" width="760" height="400" class="responsive_img" /></p>

<p>In Java, four threads run simultaneously on four cores — wall time equals t. In CPython, the same four threads run one at a time on a single core — wall time is 4t, with cores 2–4 sitting idle waiting for the GIL.</p>

<p>This has two practical consequences.</p>

<h3 id="when-the-gil-is-released">When the GIL Is Released</h3>

<p>The GIL is not held permanently. CPython releases it in two situations: during I/O waits (network, disk, database) and inside C extensions that explicitly drop it (NumPy, PyCryptodome, lxml). In both cases, other threads can run while one is waiting or computing in C.</p>

<ul>
  <li><strong>I/O-bound tasks</strong>: threads work well. When a thread waits for a network response, a file read, or a database query, CPython releases the GIL for the duration of that wait. Another thread picks it up and runs. You get real concurrency — just not on the CPU calculation itself, but on the waiting.</li>
  <li><strong>CPU-bound tasks</strong>: threads give you nothing. All threads fight over the single GIL. A CPU-heavy calculation running in four threads on a four-core machine runs no faster than on one core — and slightly slower due to context switching overhead. In contrast, a Java developer using a thread pool for CPU work gets genuine parallelism; the same pattern in CPython gives worse results than a single thread.</li>
</ul>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">threading</span>
<span class="kn">import</span> <span class="n">time</span>
<span class="kn">import</span> <span class="n">urllib.request</span>

<span class="k">def</span> <span class="nf">fetch</span><span class="p">(</span><span class="n">url</span><span class="p">):</span>
    <span class="k">with</span> <span class="n">urllib</span><span class="p">.</span><span class="n">request</span><span class="p">.</span><span class="nf">urlopen</span><span class="p">(</span><span class="n">url</span><span class="p">)</span> <span class="k">as</span> <span class="n">response</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">response</span><span class="p">.</span><span class="nf">read</span><span class="p">()</span>

<span class="n">urls</span> <span class="o">=</span> <span class="p">[</span>
    <span class="sh">"</span><span class="s">https://httpbin.org/delay/1</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">https://httpbin.org/delay/1</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">https://httpbin.org/delay/1</span><span class="sh">"</span><span class="p">,</span>
<span class="p">]</span>

<span class="n">start</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="nf">time</span><span class="p">()</span>
<span class="n">threads</span> <span class="o">=</span> <span class="p">[</span><span class="n">threading</span><span class="p">.</span><span class="nc">Thread</span><span class="p">(</span><span class="n">target</span><span class="o">=</span><span class="n">fetch</span><span class="p">,</span> <span class="n">args</span><span class="o">=</span><span class="p">(</span><span class="n">url</span><span class="p">,))</span> <span class="k">for</span> <span class="n">url</span> <span class="ow">in</span> <span class="n">urls</span><span class="p">]</span>
<span class="k">for</span> <span class="n">t</span> <span class="ow">in</span> <span class="n">threads</span><span class="p">:</span>
    <span class="n">t</span><span class="p">.</span><span class="nf">start</span><span class="p">()</span>
<span class="k">for</span> <span class="n">t</span> <span class="ow">in</span> <span class="n">threads</span><span class="p">:</span>
    <span class="n">t</span><span class="p">.</span><span class="nf">join</span><span class="p">()</span>
<span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Elapsed: </span><span class="si">{</span><span class="n">time</span><span class="p">.</span><span class="nf">time</span><span class="p">()</span> <span class="o">-</span> <span class="n">start</span><span class="si">:</span><span class="p">.</span><span class="mi">1</span><span class="n">f</span><span class="si">}</span><span class="s">s</span><span class="sh">"</span><span class="p">)</span>  <span class="c1"># ~1s, not 3s
</span></code></pre></div></div>

<p>Three one-second HTTP requests finish in roughly one second because all three threads wait concurrently while the GIL sits idle during the actual network I/O.</p>

<p>This is exactly the I/O-bound case: the GIL is released during the network wait, so the threads are genuinely concurrent. If instead of making an HTTP request each thread ran a CPU-heavy calculation, the result would be ~3 seconds — or worse, due to the extra context switching overhead on top of serial execution.</p>

<p>The following example replaces the HTTP call with a CPU-bound task — summing a large range of numbers, which keeps the GIL held the entire time:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">threading</span>
<span class="kn">import</span> <span class="n">time</span>

<span class="k">def</span> <span class="nf">cpu_task</span><span class="p">():</span>
    <span class="c1"># time.monotonic() returns a monotonically increasing clock (never goes
</span>    <span class="c1"># backwards, unaffected by system clock adjustments) — reliable for
</span>    <span class="c1"># measuring durations. We busy-loop for exactly 1s on any machine.
</span>    <span class="n">deadline</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="nf">monotonic</span><span class="p">()</span> <span class="o">+</span> <span class="mf">1.0</span>
    <span class="k">while</span> <span class="n">time</span><span class="p">.</span><span class="nf">monotonic</span><span class="p">()</span> <span class="o">&lt;</span> <span class="n">deadline</span><span class="p">:</span>
        <span class="k">pass</span>  <span class="c1"># pure Python busy loop — GIL never released
</span>
<span class="n">start</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="nf">time</span><span class="p">()</span>
<span class="n">threads</span> <span class="o">=</span> <span class="p">[</span><span class="n">threading</span><span class="p">.</span><span class="nc">Thread</span><span class="p">(</span><span class="n">target</span><span class="o">=</span><span class="n">cpu_task</span><span class="p">)</span> <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="mi">3</span><span class="p">)]</span>
<span class="k">for</span> <span class="n">t</span> <span class="ow">in</span> <span class="n">threads</span><span class="p">:</span>
    <span class="n">t</span><span class="p">.</span><span class="nf">start</span><span class="p">()</span>
<span class="k">for</span> <span class="n">t</span> <span class="ow">in</span> <span class="n">threads</span><span class="p">:</span>
    <span class="n">t</span><span class="p">.</span><span class="nf">join</span><span class="p">()</span>
<span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Elapsed: </span><span class="si">{</span><span class="n">time</span><span class="p">.</span><span class="nf">time</span><span class="p">()</span> <span class="o">-</span> <span class="n">start</span><span class="si">:</span><span class="p">.</span><span class="mi">1</span><span class="n">f</span><span class="si">}</span><span class="s">s</span><span class="sh">"</span><span class="p">)</span>  <span class="c1"># ~3s, not 1s
</span></code></pre></div></div>

<p>All three threads fight over the GIL. Only one runs at a time — the others wait. Total time is roughly three times the time of a single task, with no benefit over running them sequentially.</p>

<h3 id="free-threaded-python-313">Free-Threaded Python (3.13+)</h3>

<p><a href="https://peps.python.org/pep-0703/">PEP 703</a>, accepted in October 2023, introduced an experimental <code class="language-plaintext highlighter-rouge">--disable-gil</code> build flag in CPython 3.13, made more stable in 3.14. With the nogil build, threads can execute Python bytecode in parallel on multiple cores — real CPU-bound parallelism without <code class="language-plaintext highlighter-rouge">multiprocessing</code>.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">sys</span>
<span class="nf">print</span><span class="p">(</span><span class="n">sys</span><span class="p">.</span><span class="nf">_is_gil_enabled</span><span class="p">())</span>  <span class="c1"># False in a nogil build
</span></code></pre></div></div>

<p>The single-threaded performance penalty is approximately 5–10% (<a href="https://peps.python.org/pep-0703/#performance">PEP 703</a>). As of 2026, the nogil build is production-ready for many workloads and represents the most significant change to CPython’s concurrency model in the language’s history.</p>

<h2 id="threads-the-right-tool-for-io-bound-work">Threads: The Right Tool for I/O-Bound Work</h2>

<p>For I/O-bound concurrency where you need dozens — not thousands — of concurrent operations, <a href="https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.ThreadPoolExecutor"><code class="language-plaintext highlighter-rouge">ThreadPoolExecutor</code></a> from <a href="https://docs.python.org/3/library/concurrent.futures.html"><code class="language-plaintext highlighter-rouge">concurrent.futures</code></a> is the idiomatic choice. The <a href="https://docs.python.org/3/library/threading.html"><code class="language-plaintext highlighter-rouge">threading</code></a> module gives you lower-level control, but <code class="language-plaintext highlighter-rouge">ThreadPoolExecutor</code> handles pool management, exception propagation, and cleanup automatically.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">concurrent.futures</span> <span class="kn">import</span> <span class="n">ThreadPoolExecutor</span><span class="p">,</span> <span class="n">as_completed</span>
<span class="kn">import</span> <span class="n">urllib.request</span>

<span class="k">def</span> <span class="nf">fetch</span><span class="p">(</span><span class="n">url</span><span class="p">):</span>
    <span class="k">with</span> <span class="n">urllib</span><span class="p">.</span><span class="n">request</span><span class="p">.</span><span class="nf">urlopen</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span> <span class="k">as</span> <span class="n">r</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">url</span><span class="p">,</span> <span class="nf">len</span><span class="p">(</span><span class="n">r</span><span class="p">.</span><span class="nf">read</span><span class="p">())</span>

<span class="n">urls</span> <span class="o">=</span> <span class="p">[</span>
    <span class="sh">"</span><span class="s">https://httpbin.org/bytes/1024</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">https://httpbin.org/bytes/2048</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">https://httpbin.org/bytes/512</span><span class="sh">"</span><span class="p">,</span>
<span class="p">]</span>

<span class="k">with</span> <span class="nc">ThreadPoolExecutor</span><span class="p">(</span><span class="n">max_workers</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span> <span class="k">as</span> <span class="n">executor</span><span class="p">:</span>
    <span class="n">futures</span> <span class="o">=</span> <span class="p">{</span><span class="n">executor</span><span class="p">.</span><span class="nf">submit</span><span class="p">(</span><span class="n">fetch</span><span class="p">,</span> <span class="n">url</span><span class="p">):</span> <span class="n">url</span> <span class="k">for</span> <span class="n">url</span> <span class="ow">in</span> <span class="n">urls</span><span class="p">}</span>
    <span class="k">for</span> <span class="n">future</span> <span class="ow">in</span> <span class="nf">as_completed</span><span class="p">(</span><span class="n">futures</span><span class="p">):</span>
        <span class="n">url</span><span class="p">,</span> <span class="n">size</span> <span class="o">=</span> <span class="n">future</span><span class="p">.</span><span class="nf">result</span><span class="p">()</span>
        <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">url</span><span class="si">}</span><span class="s"> → </span><span class="si">{</span><span class="n">size</span><span class="si">}</span><span class="s"> bytes</span><span class="sh">"</span><span class="p">)</span>
</code></pre></div></div>

<p>Breaking down the key choices:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">max_workers=10</code> — caps the pool size. The default in Python 3.13+ is <code class="language-plaintext highlighter-rouge">min(32, cpu_count + 4)</code>, which preserves at least 5 workers for I/O overlap.</li>
  <li><code class="language-plaintext highlighter-rouge">as_completed()</code> — yields futures as they finish, not in submission order. Use this when tasks have different durations and you want to process results as they arrive.</li>
  <li>Context manager (<code class="language-plaintext highlighter-rouge">with</code>) — guarantees the pool shuts down cleanly and all threads complete before the block exits.</li>
</ul>

<blockquote>
  <p>Use <code class="language-plaintext highlighter-rouge">ThreadPoolExecutor</code> for I/O-bound work that needs straightforward parallel execution without handling thousands of concurrent connections.</p>
</blockquote>

<h2 id="processes-the-right-tool-for-cpu-bound-work">Processes: The Right Tool for CPU-Bound Work</h2>

<p>When you need real parallelism for CPU-heavy work — image processing, numerical computation, data transformation — use <a href="https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.ProcessPoolExecutor"><code class="language-plaintext highlighter-rouge">ProcessPoolExecutor</code></a>. Each worker runs in a separate Python interpreter with its own GIL, so all cores are genuinely available.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">concurrent.futures</span> <span class="kn">import</span> <span class="n">ProcessPoolExecutor</span>
<span class="kn">import</span> <span class="n">math</span>

<span class="k">def</span> <span class="nf">is_prime</span><span class="p">(</span><span class="n">n</span><span class="p">):</span>
    <span class="k">if</span> <span class="n">n</span> <span class="o">&lt;</span> <span class="mi">2</span><span class="p">:</span>
        <span class="k">return</span> <span class="bp">False</span>
    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="nf">int</span><span class="p">(</span><span class="n">math</span><span class="p">.</span><span class="nf">sqrt</span><span class="p">(</span><span class="n">n</span><span class="p">))</span> <span class="o">+</span> <span class="mi">1</span><span class="p">):</span>
        <span class="k">if</span> <span class="n">n</span> <span class="o">%</span> <span class="n">i</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span>
            <span class="k">return</span> <span class="bp">False</span>
    <span class="k">return</span> <span class="bp">True</span>

<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="sh">"</span><span class="s">__main__</span><span class="sh">"</span><span class="p">:</span>
    <span class="n">candidates</span> <span class="o">=</span> <span class="nf">range</span><span class="p">(</span><span class="mi">10_000_000</span><span class="p">,</span> <span class="mi">10_000_100</span><span class="p">)</span>
    <span class="k">with</span> <span class="nc">ProcessPoolExecutor</span><span class="p">()</span> <span class="k">as</span> <span class="n">executor</span><span class="p">:</span>
        <span class="n">results</span> <span class="o">=</span> <span class="nf">list</span><span class="p">(</span><span class="n">executor</span><span class="p">.</span><span class="nf">map</span><span class="p">(</span><span class="n">is_prime</span><span class="p">,</span> <span class="n">candidates</span><span class="p">))</span>
    <span class="n">primes</span> <span class="o">=</span> <span class="p">[</span><span class="n">n</span> <span class="k">for</span> <span class="n">n</span><span class="p">,</span> <span class="n">p</span> <span class="ow">in</span> <span class="nf">zip</span><span class="p">(</span><span class="n">candidates</span><span class="p">,</span> <span class="n">results</span><span class="p">)</span> <span class="k">if</span> <span class="n">p</span><span class="p">]</span>
    <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Found </span><span class="si">{</span><span class="nf">len</span><span class="p">(</span><span class="n">primes</span><span class="p">)</span><span class="si">}</span><span class="s"> primes</span><span class="sh">"</span><span class="p">)</span>
</code></pre></div></div>

<p>Two rules that catch beginners:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">if __name__ == "__main__":</code></strong> is mandatory on macOS and Windows. The <code class="language-plaintext highlighter-rouge">spawn</code> start method (default since Python 3.14 for <code class="language-plaintext highlighter-rouge">ProcessPoolExecutor</code>) imports the module in each worker. Without this guard, the import triggers new worker spawns, causing an infinite loop.</li>
  <li><strong>Only picklable objects</strong> can be passed to and returned from workers. Lambda functions, file handles, and database connections are not picklable.</li>
</ul>

<p>The default worker count is <code class="language-plaintext highlighter-rouge">os.process_cpu_count()</code> — one worker per physical core. Spawning processes is expensive; use the context manager to amortize startup cost across many tasks. For project setup and dependency management best practices, see <a href="/how-to-set-up-your-next-python-project/">How to Set Up Your Next Python Project</a>.</p>

<h3 id="interpreterpoolexecutor-python-314">InterpreterPoolExecutor (Python 3.14+)</h3>

<p>Python 3.14 introduced <code class="language-plaintext highlighter-rouge">InterpreterPoolExecutor</code>, a new executor that gives each thread its own sub-interpreter with its own GIL. It achieves true multi-core parallelism without the process spawn cost.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">concurrent.futures</span> <span class="kn">import</span> <span class="n">InterpreterPoolExecutor</span>

<span class="k">def</span> <span class="nf">cpu_task</span><span class="p">(</span><span class="n">n</span><span class="p">):</span>
    <span class="k">return</span> <span class="nf">sum</span><span class="p">(</span><span class="n">i</span> <span class="o">*</span> <span class="n">i</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="n">n</span><span class="p">))</span>

<span class="k">with</span> <span class="nc">InterpreterPoolExecutor</span><span class="p">(</span><span class="n">max_workers</span><span class="o">=</span><span class="mi">4</span><span class="p">)</span> <span class="k">as</span> <span class="n">executor</span><span class="p">:</span>
    <span class="n">results</span> <span class="o">=</span> <span class="nf">list</span><span class="p">(</span><span class="n">executor</span><span class="p">.</span><span class="nf">map</span><span class="p">(</span><span class="n">cpu_task</span><span class="p">,</span> <span class="p">[</span><span class="mi">1_000_000</span><span class="p">]</span> <span class="o">*</span> <span class="mi">4</span><span class="p">))</span>

<span class="nf">print</span><span class="p">(</span><span class="n">results</span><span class="p">)</span>
<span class="c1"># [333332833333500000, 333332833333500000,
#  333332833333500000, 333332833333500000]
# Four tasks completed in parallel — wall time ≈ 1× single task duration
</span></code></pre></div></div>

<p>Each interpreter is fully isolated — no shared mutable state. Data must still be picklable. This is the direct answer to the GIL problem for many CPU-bound workloads that previously required <code class="language-plaintext highlighter-rouge">multiprocessing</code>.</p>

<h2 id="the-three-models-compared">The Three Models Compared</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th><strong>Threads</strong></th>
      <th><strong>Processes</strong></th>
      <th><strong>Coroutines (asyncio)</strong></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>GIL impact</strong></td>
      <td>Shared GIL, limits CPU parallelism</td>
      <td>Each process has its own GIL</td>
      <td>Single thread, no GIL concern</td>
    </tr>
    <tr>
      <td><strong>Memory</strong></td>
      <td>Shared memory space</td>
      <td>Separate memory per process</td>
      <td>Single thread, minimal overhead</td>
    </tr>
    <tr>
      <td><strong>Startup cost</strong></td>
      <td>Low</td>
      <td>High (spawn)</td>
      <td>Near-zero</td>
    </tr>
    <tr>
      <td><strong>Best for</strong></td>
      <td>I/O-bound, moderate concurrency</td>
      <td>CPU-bound workloads</td>
      <td>I/O-bound, high concurrency</td>
    </tr>
    <tr>
      <td><strong>stdlib class</strong></td>
      <td><code class="language-plaintext highlighter-rouge">ThreadPoolExecutor</code></td>
      <td><code class="language-plaintext highlighter-rouge">ProcessPoolExecutor</code></td>
      <td><code class="language-plaintext highlighter-rouge">asyncio.Task</code></td>
    </tr>
    <tr>
      <td><strong>Max practical concurrency</strong></td>
      <td>Hundreds</td>
      <td>Tens (CPU count)</td>
      <td>Tens of thousands</td>
    </tr>
  </tbody>
</table>

<h2 id="the-event-loop-asyncio-for-high-concurrency-io">The Event Loop: asyncio for High-Concurrency I/O</h2>

<p>When concurrent connections number in the thousands — API gateways, WebSocket servers, real-time data feeds — thread pools hit a wall. Each thread consumes a non-trivial amount of memory for its stack, and context switching between hundreds of threads adds measurable overhead.</p>

<p>The event loop solves this with <strong>cooperative concurrency</strong>: a single thread runs many coroutines, each of which voluntarily suspends at <code class="language-plaintext highlighter-rouge">await</code> points to let others run. No threads, no context switching overhead, no GIL juggling. This is the same model that powers <a href="https://fastapi.tiangolo.com/">FastAPI</a> and that we will use in the next article when building a REST API.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">asyncio</span>
<span class="kn">import</span> <span class="n">aiohttp</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">fetch</span><span class="p">(</span><span class="n">session</span><span class="p">,</span> <span class="n">url</span><span class="p">):</span>
    <span class="k">async</span> <span class="k">with</span> <span class="n">session</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="n">url</span><span class="p">)</span> <span class="k">as</span> <span class="n">response</span><span class="p">:</span>
        <span class="k">return</span> <span class="k">await</span> <span class="n">response</span><span class="p">.</span><span class="nf">text</span><span class="p">()</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
    <span class="n">urls</span> <span class="o">=</span> <span class="p">[</span><span class="sh">"</span><span class="s">https://httpbin.org/delay/1</span><span class="sh">"</span> <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nf">range</span><span class="p">(</span><span class="mi">10</span><span class="p">)]</span>
    <span class="k">async</span> <span class="k">with</span> <span class="n">aiohttp</span><span class="p">.</span><span class="nc">ClientSession</span><span class="p">()</span> <span class="k">as</span> <span class="n">session</span><span class="p">:</span>
        <span class="n">tasks</span> <span class="o">=</span> <span class="p">[</span><span class="nf">fetch</span><span class="p">(</span><span class="n">session</span><span class="p">,</span> <span class="n">url</span><span class="p">)</span> <span class="k">for</span> <span class="n">url</span> <span class="ow">in</span> <span class="n">urls</span><span class="p">]</span>
        <span class="n">results</span> <span class="o">=</span> <span class="k">await</span> <span class="n">asyncio</span><span class="p">.</span><span class="nf">gather</span><span class="p">(</span><span class="o">*</span><span class="n">tasks</span><span class="p">)</span>
    <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Fetched </span><span class="si">{</span><span class="nf">len</span><span class="p">(</span><span class="n">results</span><span class="p">)</span><span class="si">}</span><span class="s"> responses</span><span class="sh">"</span><span class="p">)</span>

<span class="n">asyncio</span><span class="p">.</span><span class="nf">run</span><span class="p">(</span><span class="nf">main</span><span class="p">())</span>
</code></pre></div></div>

<p>Ten requests that each take one second complete in roughly one second — all ten coroutines are suspended at <code class="language-plaintext highlighter-rouge">await response.text()</code> simultaneously, and the event loop resumes each one as its response arrives.</p>

<h3 id="taskgroup-structured-concurrency-python-311">TaskGroup: Structured Concurrency (Python 3.11+)</h3>

<p><code class="language-plaintext highlighter-rouge">asyncio.gather()</code> has a subtle flaw: if one task raises an exception, the remaining tasks are <strong>not cancelled</strong> — they continue running as orphans. Python 3.11 introduced <code class="language-plaintext highlighter-rouge">TaskGroup</code>, which cancels all sibling tasks when any one fails.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">asyncio</span>
<span class="kn">import</span> <span class="n">aiohttp</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">fetch</span><span class="p">(</span><span class="n">session</span><span class="p">,</span> <span class="n">url</span><span class="p">):</span>
    <span class="k">async</span> <span class="k">with</span> <span class="n">session</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="n">url</span><span class="p">)</span> <span class="k">as</span> <span class="n">response</span><span class="p">:</span>
        <span class="n">response</span><span class="p">.</span><span class="nf">raise_for_status</span><span class="p">()</span>
        <span class="k">return</span> <span class="k">await</span> <span class="n">response</span><span class="p">.</span><span class="nf">text</span><span class="p">()</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
    <span class="n">urls</span> <span class="o">=</span> <span class="p">[</span>
        <span class="sh">"</span><span class="s">https://httpbin.org/get</span><span class="sh">"</span><span class="p">,</span>
        <span class="sh">"</span><span class="s">https://httpbin.org/status/500</span><span class="sh">"</span><span class="p">,</span>  <span class="c1"># this will fail
</span>        <span class="sh">"</span><span class="s">https://httpbin.org/get</span><span class="sh">"</span><span class="p">,</span>
    <span class="p">]</span>
    <span class="k">async</span> <span class="k">with</span> <span class="n">aiohttp</span><span class="p">.</span><span class="nc">ClientSession</span><span class="p">()</span> <span class="k">as</span> <span class="n">session</span><span class="p">:</span>
        <span class="k">async</span> <span class="k">with</span> <span class="n">asyncio</span><span class="p">.</span><span class="nc">TaskGroup</span><span class="p">()</span> <span class="k">as</span> <span class="n">tg</span><span class="p">:</span>
            <span class="n">tasks</span> <span class="o">=</span> <span class="p">[</span><span class="n">tg</span><span class="p">.</span><span class="nf">create_task</span><span class="p">(</span><span class="nf">fetch</span><span class="p">(</span><span class="n">session</span><span class="p">,</span> <span class="n">url</span><span class="p">))</span> <span class="k">for</span> <span class="n">url</span> <span class="ow">in</span> <span class="n">urls</span><span class="p">]</span>
    <span class="c1"># If any task raises, all others are cancelled automatically
</span></code></pre></div></div>

<blockquote>
  <p>Use <code class="language-plaintext highlighter-rouge">TaskGroup</code> when a failure in one task should cancel all the others — e.g. a batch of dependent requests where partial results are useless. Use <code class="language-plaintext highlighter-rouge">gather()</code> when tasks are independent and you want all results even if some fail, passing <code class="language-plaintext highlighter-rouge">return_exceptions=True</code> to collect errors instead of raising them.</p>
</blockquote>

<h3 id="mixing-sync-and-async-asyncioto_thread">Mixing Sync and Async: asyncio.to_thread()</h3>

<p>Most existing Python libraries — <code class="language-plaintext highlighter-rouge">requests</code>, <code class="language-plaintext highlighter-rouge">psycopg2</code>, <code class="language-plaintext highlighter-rouge">boto3</code> — are synchronous. Calling them directly inside an <code class="language-plaintext highlighter-rouge">async def</code> blocks the entire event loop. <a href="https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread"><code class="language-plaintext highlighter-rouge">asyncio.to_thread()</code></a> (Python 3.9+) runs a blocking function in a thread pool without blocking the loop. This is the same pattern we used in the <a href="/how-to-create-cron-jobs-in-python/">cron jobs article</a> when mixing sync schedulers with async application code.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="n">asyncio</span>
<span class="kn">import</span> <span class="n">requests</span>  <span class="c1"># synchronous library
</span>
<span class="k">def</span> <span class="nf">blocking_fetch</span><span class="p">(</span><span class="n">url</span><span class="p">):</span>
    <span class="k">return</span> <span class="n">requests</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="n">url</span><span class="p">).</span><span class="n">text</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
    <span class="c1"># Runs blocking_fetch in a thread; event loop continues while it waits
</span>    <span class="n">result</span> <span class="o">=</span> <span class="k">await</span> <span class="n">asyncio</span><span class="p">.</span><span class="nf">to_thread</span><span class="p">(</span><span class="n">blocking_fetch</span><span class="p">,</span> <span class="sh">"</span><span class="s">https://httpbin.org/get</span><span class="sh">"</span><span class="p">)</span>
    <span class="nf">print</span><span class="p">(</span><span class="n">result</span><span class="p">[:</span><span class="mi">100</span><span class="p">])</span>

<span class="n">asyncio</span><span class="p">.</span><span class="nf">run</span><span class="p">(</span><span class="nf">main</span><span class="p">())</span>
</code></pre></div></div>

<p>This replaces the older <code class="language-plaintext highlighter-rouge">loop.run_in_executor(None, func)</code> pattern and is the idiomatic bridge between sync libraries and async application code.</p>

<h2 id="which-model-should-i-use">Which Model Should I Use?</h2>

<p><img src="/assets/img/concurrency-in-python-decision-flowchart.svg" alt="Which Python concurrency model should I use?" width="760" height="400" class="responsive_img" /></p>

<p>Four questions get you to the right answer:</p>

<ol>
  <li><strong>Is the task I/O-bound?</strong> If yes, go to question 2. If no (CPU-bound), use <code class="language-plaintext highlighter-rouge">ProcessPoolExecutor</code> — or <code class="language-plaintext highlighter-rouge">InterpreterPoolExecutor</code> on Python 3.14+ to avoid process spawn cost.</li>
  <li><strong>Do you need thousands of concurrent connections?</strong> If yes, use <code class="language-plaintext highlighter-rouge">asyncio</code>. If no, continue.</li>
  <li><strong>Do you need to call synchronous libraries inside async code?</strong> Use <code class="language-plaintext highlighter-rouge">asyncio.to_thread()</code> as the bridge.</li>
  <li><strong>Moderate I/O concurrency with straightforward code?</strong> Use <code class="language-plaintext highlighter-rouge">ThreadPoolExecutor</code>.</li>
</ol>

<h3 id="scaling-to-production-gunicorn-with-uvicorn-workers">Scaling to Production: Gunicorn with Uvicorn Workers</h3>

<p>The event loop model scales further when combined with multiple worker processes. Gunicorn with Uvicorn workers runs one event loop per CPU core — back to the restaurant metaphor: multiple efficient waiters, one per kitchen station.</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gunicorn app:app <span class="nt">-w</span> 4 <span class="nt">-k</span> uvicorn.workers.UvicornWorker
</code></pre></div></div>

<p>Breaking down the options:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">-w 4</code> — four worker processes, one per CPU core. Each runs its own independent event loop.</li>
  <li><code class="language-plaintext highlighter-rouge">-k uvicorn.workers.UvicornWorker</code> — each worker uses Uvicorn’s ASGI event loop instead of a synchronous WSGI worker.</li>
</ul>

<p>This is the standard production deployment for FastAPI and Starlette applications.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this article we covered:</p>

<ul>
  <li>Why the GIL limits thread-based CPU parallelism in standard CPython, and how free-threaded Python 3.13+ changes that.</li>
  <li><code class="language-plaintext highlighter-rouge">ThreadPoolExecutor</code> for I/O-bound work with moderate concurrency.</li>
  <li><code class="language-plaintext highlighter-rouge">ProcessPoolExecutor</code> for CPU-bound workloads requiring real multi-core parallelism.</li>
  <li><code class="language-plaintext highlighter-rouge">InterpreterPoolExecutor</code> (Python 3.14+) as a process-free alternative for CPU-bound tasks.</li>
  <li>The event loop model and why it outperforms thread pools at high concurrency.</li>
  <li><code class="language-plaintext highlighter-rouge">asyncio.TaskGroup</code> for structured concurrency and safer error propagation.</li>
  <li><code class="language-plaintext highlighter-rouge">asyncio.to_thread()</code> as the bridge between synchronous libraries and async applications.</li>
  <li>Gunicorn + Uvicorn workers for scaling the event loop across all CPU cores in production.</li>
</ul>

<p>The <a href="/async-and-event-loop-in-python-asyncio-in-practice/">next article</a> puts the event loop theory into practice — building a real async news aggregator with <code class="language-plaintext highlighter-rouge">aiohttp</code>, <code class="language-plaintext highlighter-rouge">TaskGroup</code>, <code class="language-plaintext highlighter-rouge">Semaphore</code>, and <code class="language-plaintext highlighter-rouge">asyncio.timeout</code> in a single working module.</p>]]></content><author><name>Salvatore D&apos;Angelo</name></author><category term="Programming" /><category term="programming" /><category term="python" /><summary type="html"><![CDATA[Master Python concurrency: learn when to use threads, processes, or asyncio, and why the event loop powers modern high-throughput applications.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://code4projects.org/assets/img/concurrency-in-python-threads-processes-and-the-event-loop-hero.webp" /><media:content medium="image" url="https://code4projects.org/assets/img/concurrency-in-python-threads-processes-and-the-event-loop-hero.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Building a Database Layer in Python with SQLAlchemy ORM</title><link href="https://code4projects.org/database-layer-with-sqlalchemy-in-a-layered-python-architecture/" rel="alternate" type="text/html" title="Building a Database Layer in Python with SQLAlchemy ORM" /><published>2026-08-24T00:00:00+00:00</published><updated>2026-08-24T00:00:00+00:00</updated><id>https://code4projects.org/database-layer-with-sqlalchemy-in-a-layered-python-architecture</id><content type="html" xml:base="https://code4projects.org/database-layer-with-sqlalchemy-in-a-layered-python-architecture/"><![CDATA[<p><img src="/assets/img/database-layer-with-sqlalchemy-python.jpg" alt="Building a Database Layer in Python with SQLAlchemy ORM" width="760" height="400" class="responsive_img" /></p>

<h2 id="introduction">Introduction</h2>

<p>Most Python tutorials show you how to write a <code class="language-plaintext highlighter-rouge">SELECT</code> statement. They rarely show you what to do when you have ten services, each opening connections and sprinkling <code class="language-plaintext highlighter-rouge">session.commit()</code> everywhere — until one forgotten rollback corrupts your data at 2 AM.</p>

<p>This article builds the database layer of a layered Python application. It covers the <code class="language-plaintext highlighter-rouge">DatabaseSessionManager</code>, which centralises session lifecycle and transaction management, the DAO models that map your tables to Python objects, the <code class="language-plaintext highlighter-rouge">DatabaseInitializer</code> service that creates your schema at startup, and the DAO vs DTO boundary that keeps your architecture honest.</p>

<p>All the code shown here is extracted from <a href="https://github.com/sasadangelo/runalyze">runalyze</a>, a personal project I built to analyse my Garmin running workouts. You can browse the full <a href="https://github.com/sasadangelo/runalyze/tree/main/src/runanalyze">source layout</a> on GitHub.</p>

<p>This post is Part 5 of the <a href="/how-to-set-up-your-next-python-project/">How to Write Modern Python Applications</a> series. The previous article covered <a href="/how-to-create-cron-jobs-in-python/">How to Create Cron Jobs in Python for Your Applications</a>.</p>

<p>You should read this article if:</p>

<ul>
  <li>you want a reusable, transactional database layer for any Python application (batch job, API, web app)</li>
  <li>you want SQLAlchemy’s ORM to handle SQL for you, without writing raw queries</li>
  <li>you want session lifecycle — commit, rollback, close — managed in one place so services never have to think about it</li>
</ul>

<h2 id="project-layout">Project Layout</h2>

<p>Think of the database layer as a set of concentric rings. At the centre is the raw connection (<code class="language-plaintext highlighter-rouge">database.py</code>). Wrapping it are the table mappings (<code class="language-plaintext highlighter-rouge">models/</code>). Around those are the services that do the actual work. Controllers and routes sit on the outermost ring — they call services, and nothing more.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>src/runanalyze/
│
├── core/
│   ├── config.py              # Pydantic Settings (Part 3 of this series)
│   └── database/
│       └── database.py        # DatabaseSessionManager
│
├── models/
│   ├── base.py                # SQLAlchemy declarative Base
│   ├── activity.py            # ActivityDAO
│   └── activity_sample.py     # ActivitySampleDAO
│
├── services/
│   ├── db_initializer.py      # Creates tables at startup
│   └── garmin_sync.py         # Business logic (Garmin data fetch &amp; sync)
│
├── controllers/               # Call services, never DAOs directly
└── routes/                    # Flask/FastAPI routes
</code></pre></div></div>

<blockquote>
  <p>The <code class="language-plaintext highlighter-rouge">models/</code> folder holds <strong>Data Access Objects (DAOs)</strong> — pure table-to-class mappings. Business logic lives exclusively in <code class="language-plaintext highlighter-rouge">services/</code>. Controllers and routes never touch a DAO directly.</p>
</blockquote>

<h2 id="the-declarative-base">The Declarative Base</h2>

<p>Before you can define any table, SQLAlchemy needs a registry to collect all model metadata. One line creates it, in <a href="https://github.com/sasadangelo/runalyze/blob/main/src/runanalyze/models/base.py"><code class="language-plaintext highlighter-rouge">models/base.py</code></a>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">sqlalchemy.orm</span> <span class="kn">import</span> <span class="n">declarative_base</span>

<span class="n">Base</span> <span class="o">=</span> <span class="nf">declarative_base</span><span class="p">()</span>
</code></pre></div></div>

<p>Every DAO you write inherits from this <code class="language-plaintext highlighter-rouge">Base</code>. When you later call <code class="language-plaintext highlighter-rouge">Base.metadata.create_all(engine)</code>, SQLAlchemy uses this registry to create every table it knows about in a single pass.</p>

<h2 id="configuration">Configuration</h2>

<p>The session manager reads its database URL from the <code class="language-plaintext highlighter-rouge">Settings</code> object. The full explanation of how Pydantic Settings works is in the <a href="/managing-application-configuration-in-python-with-pydantic-settings/">previous article</a>. Here is the relevant section of <a href="https://github.com/sasadangelo/runalyze/blob/main/src/config.yaml"><code class="language-plaintext highlighter-rouge">config.yaml</code></a>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">database</span><span class="pi">:</span>
  <span class="na">sqlite</span><span class="pi">:</span>
    <span class="na">path</span><span class="pi">:</span> <span class="s2">"</span><span class="s">data/garmin.db"</span>
    <span class="na">echo</span><span class="pi">:</span> <span class="kc">false</span>
    <span class="na">pool_pre_ping</span><span class="pi">:</span> <span class="kc">true</span>

<span class="na">log</span><span class="pi">:</span>
  <span class="na">level</span><span class="pi">:</span> <span class="s2">"</span><span class="s">INFO"</span>
  <span class="na">console</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">file</span><span class="pi">:</span> <span class="s2">"</span><span class="s">logs/runanalyze.log"</span>
  <span class="na">rotation</span><span class="pi">:</span> <span class="s2">"</span><span class="s">10</span><span class="nv"> </span><span class="s">MB"</span>
  <span class="na">retention</span><span class="pi">:</span> <span class="s2">"</span><span class="s">7</span><span class="nv"> </span><span class="s">days"</span>
  <span class="na">compression</span><span class="pi">:</span> <span class="s2">"</span><span class="s">zip"</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">database.sqlite</code> section maps to a <code class="language-plaintext highlighter-rouge">SQLiteSettings</code> Pydantic model, which exposes computed properties like <code class="language-plaintext highlighter-rouge">absolute_path</code> and <code class="language-plaintext highlighter-rouge">database_url</code> used by the session manager. For a PostgreSQL backend, replace the <code class="language-plaintext highlighter-rouge">sqlite</code> block with <code class="language-plaintext highlighter-rouge">host</code>, <code class="language-plaintext highlighter-rouge">port</code>, <code class="language-plaintext highlighter-rouge">user</code>, and <code class="language-plaintext highlighter-rouge">dbname</code>, and inject the password via an environment variable.</p>

<h2 id="dao-vs-dto">DAO vs DTO</h2>

<p>Before diving into the code, one conceptual boundary deserves its own section — because getting it wrong quietly corrupts the architecture.</p>

<p>A <strong>DAO (Data Access Object)</strong> is a Python class that mirrors a database table. It is a SQLAlchemy ORM model. Its job is to represent a row in memory and participate in transactions. It lives in the <code class="language-plaintext highlighter-rouge">models/</code> layer.</p>

<p>A <strong>DTO (Data Transfer Object)</strong> is a plain data class — a <code class="language-plaintext highlighter-rouge">dataclass</code>, a Pydantic model, a <code class="language-plaintext highlighter-rouge">TypedDict</code> — that carries data between layers. No SQLAlchemy, no session, no database awareness whatsoever.</p>

<p>The critical rule is this:</p>

<blockquote>
  <p><strong>A DAO must never cross a service boundary.</strong> Controllers, routes, and any caller outside the service layer must only ever see DTOs.</p>
</blockquote>

<p>Why does this matter? If a controller receives an <code class="language-plaintext highlighter-rouge">ActivityDAO</code> instance directly, it is holding a live SQLAlchemy object bound to a session. The session may be closed. Accessing a lazy-loaded relationship raises a <code class="language-plaintext highlighter-rouge">DetachedInstanceError</code>. Serialising it to JSON is not straightforward. Testing the controller requires a real database.</p>

<p>The clean version:</p>

<ol>
  <li>The service queries the database, gets back <code class="language-plaintext highlighter-rouge">ActivityDAO</code> objects.</li>
  <li>The service maps them to <code class="language-plaintext highlighter-rouge">ActivityDTO</code> (a plain Pydantic model or dataclass).</li>
  <li>The service returns the DTO.</li>
  <li>The controller receives the DTO and does whatever it wants with it — serialize it, pass it further, log it.</li>
</ol>

<p>In the <code class="language-plaintext highlighter-rouge">runalyze</code> project, the batch sync service does not have a controller above it, so no DTOs are used today. But the boundary is still respected: the DAOs never leave the service. The moment a controller or API route is added, DTOs become mandatory.</p>

<h2 id="the-databasesessionmanager">The DatabaseSessionManager</h2>

<p>The <a href="https://github.com/sasadangelo/runalyze/blob/main/src/runanalyze/core/database/database.py"><code class="language-plaintext highlighter-rouge">DatabaseSessionManager</code></a> is the single object responsible for creating the engine, building sessions, and managing transactions. Services never call <code class="language-plaintext highlighter-rouge">session.commit()</code> or <code class="language-plaintext highlighter-rouge">session.rollback()</code> directly — the context manager does it for them.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># core/database/database.py
</span><span class="kn">from</span> <span class="n">collections.abc</span> <span class="kn">import</span> <span class="n">Generator</span>
<span class="kn">from</span> <span class="n">contextlib</span> <span class="kn">import</span> <span class="n">contextmanager</span>
<span class="kn">from</span> <span class="n">pathlib</span> <span class="kn">import</span> <span class="n">Path</span>

<span class="kn">from</span> <span class="n">sqlalchemy</span> <span class="kn">import</span> <span class="n">create_engine</span><span class="p">,</span> <span class="n">event</span>
<span class="kn">from</span> <span class="n">sqlalchemy.exc</span> <span class="kn">import</span> <span class="n">SQLAlchemyError</span>
<span class="kn">from</span> <span class="n">sqlalchemy.orm</span> <span class="kn">import</span> <span class="n">Session</span><span class="p">,</span> <span class="n">sessionmaker</span>

<span class="kn">from</span> <span class="n">runanalyze.core.config</span> <span class="kn">import</span> <span class="n">SQLiteSettings</span><span class="p">,</span> <span class="n">config</span>


<span class="k">class</span> <span class="nc">DatabaseSessionManager</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">sqlite_settings</span><span class="p">:</span> <span class="n">SQLiteSettings</span> <span class="o">=</span> <span class="n">config</span><span class="p">.</span><span class="n">database</span><span class="p">.</span><span class="n">sqlite</span>

        <span class="c1"># Ensure the target directory exists
</span>        <span class="n">db_file_path</span><span class="p">:</span> <span class="n">Path</span> <span class="o">=</span> <span class="n">sqlite_settings</span><span class="p">.</span><span class="n">absolute_path</span>
        <span class="n">db_file_path</span><span class="p">.</span><span class="n">parent</span><span class="p">.</span><span class="nf">mkdir</span><span class="p">(</span><span class="n">parents</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">exist_ok</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>

        <span class="n">self</span><span class="p">.</span><span class="n">database_url</span> <span class="o">=</span> <span class="n">sqlite_settings</span><span class="p">.</span><span class="n">database_url</span>

        <span class="n">self</span><span class="p">.</span><span class="n">engine</span> <span class="o">=</span> <span class="nf">create_engine</span><span class="p">(</span>
            <span class="n">self</span><span class="p">.</span><span class="n">database_url</span><span class="p">,</span>
            <span class="n">echo</span><span class="o">=</span><span class="n">sqlite_settings</span><span class="p">.</span><span class="n">echo</span><span class="p">,</span>
            <span class="n">pool_pre_ping</span><span class="o">=</span><span class="n">sqlite_settings</span><span class="p">.</span><span class="n">pool_pre_ping</span><span class="p">,</span>
            <span class="n">connect_args</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">check_same_thread</span><span class="sh">"</span><span class="p">:</span> <span class="bp">False</span><span class="p">}</span>
            <span class="k">if</span> <span class="n">self</span><span class="p">.</span><span class="n">database_url</span><span class="p">.</span><span class="nf">startswith</span><span class="p">(</span><span class="sh">"</span><span class="s">sqlite</span><span class="sh">"</span><span class="p">)</span>
            <span class="k">else</span> <span class="p">{},</span>
        <span class="p">)</span>

        <span class="c1"># SQLite only: enforce foreign-key constraints
</span>        <span class="nd">@event.listens_for</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">engine</span><span class="p">,</span> <span class="sh">"</span><span class="s">connect</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">def</span> <span class="nf">set_sqlite_pragma</span><span class="p">(</span><span class="n">dbapi_connection</span><span class="p">,</span> <span class="n">connection_record</span><span class="p">):</span>
            <span class="n">cursor</span> <span class="o">=</span> <span class="n">dbapi_connection</span><span class="p">.</span><span class="nf">cursor</span><span class="p">()</span>
            <span class="n">cursor</span><span class="p">.</span><span class="nf">execute</span><span class="p">(</span><span class="sh">"</span><span class="s">PRAGMA foreign_keys=ON</span><span class="sh">"</span><span class="p">)</span>
            <span class="n">cursor</span><span class="p">.</span><span class="nf">close</span><span class="p">()</span>

        <span class="n">self</span><span class="p">.</span><span class="n">SessionFactory</span> <span class="o">=</span> <span class="nf">sessionmaker</span><span class="p">(</span>
            <span class="n">bind</span><span class="o">=</span><span class="n">self</span><span class="p">.</span><span class="n">engine</span><span class="p">,</span> <span class="n">autocommit</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span> <span class="n">autoflush</span><span class="o">=</span><span class="bp">False</span>
        <span class="p">)</span>

    <span class="nd">@contextmanager</span>
    <span class="k">def</span> <span class="nf">get_session</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Generator</span><span class="p">[</span><span class="n">Session</span><span class="p">,</span> <span class="bp">None</span><span class="p">,</span> <span class="bp">None</span><span class="p">]:</span>
        <span class="sh">"""</span><span class="s">Yields a transactional session. Commits on success, rolls back on error.</span><span class="sh">"""</span>
        <span class="n">session</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nc">SessionFactory</span><span class="p">()</span>
        <span class="k">try</span><span class="p">:</span>
            <span class="k">yield</span> <span class="n">session</span>
            <span class="n">session</span><span class="p">.</span><span class="nf">commit</span><span class="p">()</span>
        <span class="k">except</span> <span class="n">SQLAlchemyError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
            <span class="n">session</span><span class="p">.</span><span class="nf">rollback</span><span class="p">()</span>
            <span class="k">raise</span> <span class="n">e</span>
        <span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
            <span class="n">session</span><span class="p">.</span><span class="nf">rollback</span><span class="p">()</span>
            <span class="k">raise</span> <span class="n">e</span>
        <span class="k">finally</span><span class="p">:</span>
            <span class="n">session</span><span class="p">.</span><span class="nf">close</span><span class="p">()</span>


<span class="c1"># Global instance — import this in every service
</span><span class="n">db_manager</span><span class="p">:</span> <span class="n">DatabaseSessionManager</span> <span class="o">=</span> <span class="nc">DatabaseSessionManager</span><span class="p">()</span>
</code></pre></div></div>

<p>A few design decisions are worth understanding:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">yield</code> inside <code class="language-plaintext highlighter-rouge">@contextmanager</code></strong> — control returns to the caller (<code class="language-plaintext highlighter-rouge">with</code> block) at the <code class="language-plaintext highlighter-rouge">yield</code> line. When the block exits, execution resumes in the <code class="language-plaintext highlighter-rouge">try/except/finally</code>. The caller never manages the transaction.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">pool_pre_ping=True</code></strong> — before lending a connection from the pool, SQLAlchemy sends a lightweight <code class="language-plaintext highlighter-rouge">SELECT 1</code>. Stale connections dropped by the server after idle timeout are discarded and replaced transparently.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">check_same_thread=False</code></strong> — SQLite rejects connections used from a thread other than the one that created them. SQLAlchemy’s session isolation makes multi-thread use safe, so this restriction is lifted explicitly.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">PRAGMA foreign_keys=ON</code></strong> — SQLite does not enforce foreign-key constraints by default. This listener fires on every new connection and switches enforcement on, so <code class="language-plaintext highlighter-rouge">ON DELETE CASCADE</code> works as expected.</li>
</ul>

<blockquote>
  <p>If a service method raises after <code class="language-plaintext highlighter-rouge">yield</code>, the <code class="language-plaintext highlighter-rouge">except</code> block executes rollback. If it exits cleanly, <code class="language-plaintext highlighter-rouge">commit</code> fires. The service never decides which one to call.</p>
</blockquote>

<h2 id="dao-models">DAO Models</h2>

<p>A DAO is a Python class that mirrors a database table. SQLAlchemy translates between instances of that class and rows in the table.</p>

<h3 id="activitydao">ActivityDAO</h3>

<p>The <a href="https://github.com/sasadangelo/runalyze/blob/main/src/runanalyze/models/activity.py"><code class="language-plaintext highlighter-rouge">ActivityDAO</code></a> maps the <code class="language-plaintext highlighter-rouge">activities</code> table, which holds one row per Garmin workout:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># models/activity.py
</span><span class="kn">from</span> <span class="n">sqlalchemy</span> <span class="kn">import</span> <span class="n">BigInteger</span><span class="p">,</span> <span class="n">Column</span><span class="p">,</span> <span class="n">Float</span><span class="p">,</span> <span class="n">String</span>
<span class="kn">from</span> <span class="n">sqlalchemy.orm</span> <span class="kn">import</span> <span class="n">relationship</span>

<span class="kn">from</span> <span class="n">.base</span> <span class="kn">import</span> <span class="n">Base</span>


<span class="k">class</span> <span class="nc">ActivityDAO</span><span class="p">(</span><span class="n">Base</span><span class="p">):</span>
    <span class="n">__tablename__</span> <span class="o">=</span> <span class="sh">"</span><span class="s">activities</span><span class="sh">"</span>

    <span class="nb">id</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">BigInteger</span><span class="p">,</span> <span class="n">primary_key</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>  <span class="c1"># Garmin Connect activity ID
</span>    <span class="n">name</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="nc">String</span><span class="p">(</span><span class="n">length</span><span class="o">=</span><span class="mi">120</span><span class="p">),</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">activity_type</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="nc">String</span><span class="p">(</span><span class="n">length</span><span class="o">=</span><span class="mi">50</span><span class="p">),</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">start_time</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="nc">String</span><span class="p">(</span><span class="n">length</span><span class="o">=</span><span class="mi">50</span><span class="p">),</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">duration_secs</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">Float</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">distance_meters</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">Float</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">avg_hr</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">Float</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">max_hr</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">Float</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">calories</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">Float</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>
    <span class="n">avg_speed_m_s</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">Float</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">False</span><span class="p">)</span>

    <span class="c1"># Training metrics computed from raw samples
</span>    <span class="n">tss</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">Float</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">vo2max</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">Float</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">aerobic_decoupling</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">Float</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">aerobic_decoupling_pure</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">Float</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>

    <span class="c1"># One-to-many: second-by-second heart rate and speed samples
</span>    <span class="n">samples</span> <span class="o">=</span> <span class="nf">relationship</span><span class="p">(</span>
        <span class="sh">"</span><span class="s">ActivitySampleDAO</span><span class="sh">"</span><span class="p">,</span>
        <span class="n">back_populates</span><span class="o">=</span><span class="sh">"</span><span class="s">activity</span><span class="sh">"</span><span class="p">,</span>
        <span class="n">cascade</span><span class="o">=</span><span class="sh">"</span><span class="s">all, delete-orphan</span><span class="sh">"</span><span class="p">,</span>
        <span class="n">passive_deletes</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
    <span class="p">)</span>

    <span class="c1"># One-to-one: weather conditions at the time of the activity
</span>    <span class="n">weather</span> <span class="o">=</span> <span class="nf">relationship</span><span class="p">(</span>
        <span class="sh">"</span><span class="s">ActivityWeatherDAO</span><span class="sh">"</span><span class="p">,</span>
        <span class="n">back_populates</span><span class="o">=</span><span class="sh">"</span><span class="s">activity</span><span class="sh">"</span><span class="p">,</span>
        <span class="n">cascade</span><span class="o">=</span><span class="sh">"</span><span class="s">all, delete-orphan</span><span class="sh">"</span><span class="p">,</span>
        <span class="n">passive_deletes</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
        <span class="n">uselist</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span>
    <span class="p">)</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">relationship</code> fields do not create columns. They tell SQLAlchemy how to follow foreign keys to load related rows. Two options work together on the <code class="language-plaintext highlighter-rouge">samples</code> relationship:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">cascade="all, delete-orphan"</code> — when an <code class="language-plaintext highlighter-rouge">ActivityDAO</code> is deleted, all its child <code class="language-plaintext highlighter-rouge">ActivitySampleDAO</code> rows are deleted too, at the ORM level</li>
  <li><code class="language-plaintext highlighter-rouge">passive_deletes=True</code> — also lets the database’s own <code class="language-plaintext highlighter-rouge">ON DELETE CASCADE</code> handle the deletion, which is far more efficient for large child sets than issuing individual <code class="language-plaintext highlighter-rouge">DELETE</code> statements</li>
</ul>

<p>The <code class="language-plaintext highlighter-rouge">weather</code> relationship adds <code class="language-plaintext highlighter-rouge">uselist=False</code> because there is exactly one weather record per activity — a one-to-one association.</p>

<h3 id="activitysampledao">ActivitySampleDAO</h3>

<p>The <a href="https://github.com/sasadangelo/runalyze/blob/main/src/runanalyze/models/activity_sample.py"><code class="language-plaintext highlighter-rouge">ActivitySampleDAO</code></a> maps the <code class="language-plaintext highlighter-rouge">activity_samples</code> table, which stores per-second heart rate and speed for every workout:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># models/activity_sample.py
</span><span class="kn">from</span> <span class="n">sqlalchemy</span> <span class="kn">import</span> <span class="n">BigInteger</span><span class="p">,</span> <span class="n">Column</span><span class="p">,</span> <span class="n">Float</span><span class="p">,</span> <span class="n">ForeignKey</span><span class="p">,</span> <span class="n">Integer</span>
<span class="kn">from</span> <span class="n">sqlalchemy.orm</span> <span class="kn">import</span> <span class="n">relationship</span>

<span class="kn">from</span> <span class="n">.base</span> <span class="kn">import</span> <span class="n">Base</span>


<span class="k">class</span> <span class="nc">ActivitySampleDAO</span><span class="p">(</span><span class="n">Base</span><span class="p">):</span>
    <span class="n">__tablename__</span> <span class="o">=</span> <span class="sh">"</span><span class="s">activity_samples</span><span class="sh">"</span>

    <span class="n">activity_id</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span>
        <span class="n">BigInteger</span><span class="p">,</span>
        <span class="nc">ForeignKey</span><span class="p">(</span><span class="sh">"</span><span class="s">activities.id</span><span class="sh">"</span><span class="p">,</span> <span class="n">ondelete</span><span class="o">=</span><span class="sh">"</span><span class="s">CASCADE</span><span class="sh">"</span><span class="p">),</span>
        <span class="n">primary_key</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
    <span class="p">)</span>
    <span class="n">timestamp_secs</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span>
        <span class="n">Integer</span><span class="p">,</span> <span class="n">primary_key</span><span class="o">=</span><span class="bp">True</span>
    <span class="p">)</span>  <span class="c1"># seconds elapsed since activity start
</span>    <span class="n">heart_rate</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">Integer</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">speed_m_s</span> <span class="o">=</span> <span class="nc">Column</span><span class="p">(</span><span class="n">Float</span><span class="p">,</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>

    <span class="n">activity</span> <span class="o">=</span> <span class="nf">relationship</span><span class="p">(</span><span class="sh">"</span><span class="s">ActivityDAO</span><span class="sh">"</span><span class="p">,</span> <span class="n">back_populates</span><span class="o">=</span><span class="sh">"</span><span class="s">samples</span><span class="sh">"</span><span class="p">)</span>
</code></pre></div></div>

<p>The composite primary key <code class="language-plaintext highlighter-rouge">(activity_id, timestamp_secs)</code> enforces uniqueness at the database level: the same activity cannot have two samples at the same second.</p>

<h2 id="the-databaseinitializer">The DatabaseInitializer</h2>

<p>The <a href="https://github.com/sasadangelo/runalyze/blob/main/src/runanalyze/services/db_initializer.py"><code class="language-plaintext highlighter-rouge">DatabaseInitializer</code></a> is a startup service with one job: create every table that does not yet exist. It lives in <code class="language-plaintext highlighter-rouge">services/</code> — not in <code class="language-plaintext highlighter-rouge">core/</code> — because schema initialisation is a business-level decision, not an infrastructure primitive.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># services/db_initializer.py
</span><span class="kn">from</span> <span class="n">runanalyze.core.database</span> <span class="kn">import</span> <span class="n">db_manager</span>
<span class="kn">from</span> <span class="n">runanalyze.models.activity</span> <span class="kn">import</span> <span class="n">ActivityDAO</span>  <span class="c1"># noqa: F401
</span><span class="kn">from</span> <span class="n">runanalyze.models.activity_sample</span> <span class="kn">import</span> <span class="n">ActivitySampleDAO</span>  <span class="c1"># noqa: F401
</span><span class="kn">from</span> <span class="n">runanalyze.models.activity_weather</span> <span class="kn">import</span> <span class="n">ActivityWeatherDAO</span>  <span class="c1"># noqa: F401
</span><span class="kn">from</span> <span class="n">runanalyze.models.base</span> <span class="kn">import</span> <span class="n">Base</span>
<span class="kn">from</span> <span class="n">runanalyze.models.daily_metrics</span> <span class="kn">import</span> <span class="n">DailyMetricsDAO</span>  <span class="c1"># noqa: F401
</span>

<span class="k">class</span> <span class="nc">DatabaseInitializer</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">self</span><span class="p">.</span><span class="n">_engine</span> <span class="o">=</span> <span class="n">db_manager</span><span class="p">.</span><span class="n">engine</span>

    <span class="k">def</span> <span class="nf">initialize_tables</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="sh">"""</span><span class="s">Create tables if they do not already exist.</span><span class="sh">"""</span>
        <span class="k">try</span><span class="p">:</span>
            <span class="n">Base</span><span class="p">.</span><span class="n">metadata</span><span class="p">.</span><span class="nf">create_all</span><span class="p">(</span><span class="n">bind</span><span class="o">=</span><span class="n">self</span><span class="p">.</span><span class="n">_engine</span><span class="p">)</span>
        <span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
            <span class="k">raise</span> <span class="n">e</span>
</code></pre></div></div>

<p>The DAO imports look like dead code, but they are essential. SQLAlchemy’s <code class="language-plaintext highlighter-rouge">Base.metadata</code> only knows about tables whose classes have been imported at least once. Without those imports, <code class="language-plaintext highlighter-rouge">create_all</code> would find an empty registry and create nothing.</p>

<blockquote>
  <p><strong><code class="language-plaintext highlighter-rouge">create_all</code> is idempotent</strong> — it checks whether each table exists before trying to create it. Running it on every startup is safe and removes the need for any startup condition check.</p>
</blockquote>

<h2 id="using-sessions-in-a-service">Using Sessions in a Service</h2>

<p>With the infrastructure in place, a service uses <code class="language-plaintext highlighter-rouge">db_manager.get_session()</code> as a plain context manager. Here is a simplified version of the Garmin sync service:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># services/garmin_sync.py
</span><span class="kn">from</span> <span class="n">runanalyze.core.database</span> <span class="kn">import</span> <span class="n">db_manager</span>
<span class="kn">from</span> <span class="n">runanalyze.models.activity</span> <span class="kn">import</span> <span class="n">ActivityDAO</span>
<span class="kn">from</span> <span class="n">sqlalchemy</span> <span class="kn">import</span> <span class="n">exists</span>


<span class="k">class</span> <span class="nc">GarminSyncService</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">save_if_new</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">activity_data</span><span class="p">:</span> <span class="nb">dict</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">activity_id</span> <span class="o">=</span> <span class="n">activity_data</span><span class="p">[</span><span class="sh">"</span><span class="s">activityId</span><span class="sh">"</span><span class="p">]</span>

        <span class="k">with</span> <span class="n">db_manager</span><span class="p">.</span><span class="nf">get_session</span><span class="p">()</span> <span class="k">as</span> <span class="n">session</span><span class="p">:</span>
            <span class="n">already_exists</span> <span class="o">=</span> <span class="n">session</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span>
                <span class="nf">exists</span><span class="p">().</span><span class="nf">where</span><span class="p">(</span><span class="n">ActivityDAO</span><span class="p">.</span><span class="nb">id</span> <span class="o">==</span> <span class="n">activity_id</span><span class="p">)</span>
            <span class="p">).</span><span class="nf">scalar</span><span class="p">()</span>

            <span class="k">if</span> <span class="n">already_exists</span><span class="p">:</span>
                <span class="k">return</span>

            <span class="n">new_activity</span> <span class="o">=</span> <span class="nc">ActivityDAO</span><span class="p">(</span>
                <span class="nb">id</span><span class="o">=</span><span class="n">activity_id</span><span class="p">,</span>
                <span class="n">name</span><span class="o">=</span><span class="n">activity_data</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">activityName</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Unknown</span><span class="sh">"</span><span class="p">),</span>
                <span class="n">start_time</span><span class="o">=</span><span class="n">activity_data</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">startTimeLocal</span><span class="sh">"</span><span class="p">),</span>
                <span class="n">duration_secs</span><span class="o">=</span><span class="n">activity_data</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">duration</span><span class="sh">"</span><span class="p">,</span> <span class="mf">0.0</span><span class="p">),</span>
                <span class="n">distance_meters</span><span class="o">=</span><span class="n">activity_data</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">distance</span><span class="sh">"</span><span class="p">,</span> <span class="mf">0.0</span><span class="p">),</span>
                <span class="n">avg_hr</span><span class="o">=</span><span class="n">activity_data</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">averageHR</span><span class="sh">"</span><span class="p">),</span>
                <span class="n">max_hr</span><span class="o">=</span><span class="n">activity_data</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">maxHR</span><span class="sh">"</span><span class="p">),</span>
                <span class="n">calories</span><span class="o">=</span><span class="n">activity_data</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">calories</span><span class="sh">"</span><span class="p">,</span> <span class="mf">0.0</span><span class="p">),</span>
                <span class="n">avg_speed_m_s</span><span class="o">=</span><span class="n">activity_data</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">averageSpeed</span><span class="sh">"</span><span class="p">,</span> <span class="mf">0.0</span><span class="p">),</span>
            <span class="p">)</span>
            <span class="n">session</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="n">new_activity</span><span class="p">)</span>
        <span class="c1"># commit fires automatically when the `with` block exits cleanly
</span></code></pre></div></div>

<p>No <code class="language-plaintext highlighter-rouge">session.commit()</code>. No <code class="language-plaintext highlighter-rouge">session.rollback()</code>. No <code class="language-plaintext highlighter-rouge">session.close()</code>. All of that is owned by the <code class="language-plaintext highlighter-rouge">DatabaseSessionManager</code>. And note that <code class="language-plaintext highlighter-rouge">new_activity</code> — an <code class="language-plaintext highlighter-rouge">ActivityDAO</code> — never leaves this method. The caller gets nothing back, or it would get a DTO in a real API scenario.</p>

<h2 id="simple-queries-vs-the-repository-pattern">Simple Queries vs. the Repository Pattern</h2>

<p>For most CRUD operations, SQLAlchemy’s ORM is sufficient and requires no extra abstraction:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Fetch all activities
</span><span class="n">activities</span> <span class="o">=</span> <span class="n">session</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">ActivityDAO</span><span class="p">).</span><span class="nf">all</span><span class="p">()</span>

<span class="c1"># Fetch by primary key
</span><span class="n">activity</span> <span class="o">=</span> <span class="n">session</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="n">ActivityDAO</span><span class="p">,</span> <span class="n">activity_id</span><span class="p">)</span>

<span class="c1"># Filter by date
</span><span class="n">recent</span> <span class="o">=</span> <span class="n">session</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">ActivityDAO</span><span class="p">).</span><span class="nf">filter</span><span class="p">(</span><span class="n">ActivityDAO</span><span class="p">.</span><span class="n">start_time</span> <span class="o">&gt;=</span> <span class="sh">"</span><span class="s">2024-01-01</span><span class="sh">"</span><span class="p">).</span><span class="nf">all</span><span class="p">()</span>
</code></pre></div></div>

<p>These reads are clean, readable, and need no additional layer.</p>

<p>Complex queries are a different story. Imagine you need the top-10 activities by average heart rate, joined with aggregated sample counts, filtered by activity type and date range. That logic does not belong directly in a service method — it mixes business intent with query mechanics and becomes hard to test.</p>

<p>The <strong>Repository Pattern</strong> solves this by giving each aggregate root its own class that owns all query logic for that table:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">ActivityRepository</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">session</span><span class="p">:</span> <span class="n">Session</span><span class="p">):</span>
        <span class="n">self</span><span class="p">.</span><span class="n">_session</span> <span class="o">=</span> <span class="n">session</span>

    <span class="k">def</span> <span class="nf">top_by_avg_hr</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">10</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="n">ActivityDAO</span><span class="p">]:</span>
        <span class="nf">return </span><span class="p">(</span>
            <span class="n">self</span><span class="p">.</span><span class="n">_session</span><span class="p">.</span><span class="nf">query</span><span class="p">(</span><span class="n">ActivityDAO</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">filter</span><span class="p">(</span><span class="n">ActivityDAO</span><span class="p">.</span><span class="n">avg_hr</span><span class="p">.</span><span class="nf">is_not</span><span class="p">(</span><span class="bp">None</span><span class="p">))</span>
            <span class="p">.</span><span class="nf">order_by</span><span class="p">(</span><span class="n">ActivityDAO</span><span class="p">.</span><span class="n">avg_hr</span><span class="p">.</span><span class="nf">desc</span><span class="p">())</span>
            <span class="p">.</span><span class="nf">limit</span><span class="p">(</span><span class="n">limit</span><span class="p">)</span>
            <span class="p">.</span><span class="nf">all</span><span class="p">()</span>
        <span class="p">)</span>
</code></pre></div></div>

<p>The service stays clean — it calls <code class="language-plaintext highlighter-rouge">repo.top_by_avg_hr()</code> and never sees the query internals. The repository returns DAOs, which the service immediately maps to DTOs before returning to the caller.</p>

<p>Use the Repository Pattern when queries grow beyond two or three filter conditions, or when the same complex query appears in more than one service.</p>

<h2 id="schema-migrations-with-alembic">Schema Migrations with Alembic</h2>

<p><code class="language-plaintext highlighter-rouge">create_all</code> is fine for development and greenfield deployments. The moment you need to add a column, rename a table, or drop an index on a database that already holds production data, you need migrations.</p>

<p><a href="https://alembic.sqlalchemy.org/">Alembic</a> is the standard migration tool for SQLAlchemy. It tracks schema changes in versioned scripts, applies them in order, and supports rollback. A typical migration looks like this:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># alembic/versions/0001_add_elevation_to_activities.py
</span><span class="k">def</span> <span class="nf">upgrade</span><span class="p">():</span>
    <span class="n">op</span><span class="p">.</span><span class="nf">add_column</span><span class="p">(</span>
        <span class="sh">"</span><span class="s">activities</span><span class="sh">"</span><span class="p">,</span> <span class="n">sa</span><span class="p">.</span><span class="nc">Column</span><span class="p">(</span><span class="sh">"</span><span class="s">elevation_meters</span><span class="sh">"</span><span class="p">,</span> <span class="n">sa</span><span class="p">.</span><span class="nc">Float</span><span class="p">(),</span> <span class="n">nullable</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="p">)</span>


<span class="k">def</span> <span class="nf">downgrade</span><span class="p">():</span>
    <span class="n">op</span><span class="p">.</span><span class="nf">drop_column</span><span class="p">(</span><span class="sh">"</span><span class="s">activities</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">elevation_meters</span><span class="sh">"</span><span class="p">)</span>
</code></pre></div></div>

<p>Setting up Alembic is beyond the scope of this article. The short version — run these three commands in your project root:</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>alembic init alembic
alembic revision <span class="nt">--autogenerate</span> <span class="nt">-m</span> <span class="s2">"initial schema"</span>
alembic upgrade <span class="nb">head</span>
</code></pre></div></div>

<p>Breaking down the <code class="language-plaintext highlighter-rouge">revision</code> command:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">--autogenerate</code> — compares your current SQLAlchemy models against the live database schema and generates the migration script automatically</li>
  <li><code class="language-plaintext highlighter-rouge">-m "initial schema"</code> — labels the migration version with a human-readable message</li>
</ul>

<p>After that, every schema change follows the same cycle: update your models, run <code class="language-plaintext highlighter-rouge">alembic revision --autogenerate -m "description"</code>, review the generated script, then <code class="language-plaintext highlighter-rouge">alembic upgrade head</code> to apply it.</p>

<h2 id="admin-vs-application-user">Admin vs. Application User</h2>

<p>In production databases — PostgreSQL in particular — the application should never connect as an admin user. A common setup uses two credentials:</p>

<ul>
  <li><strong>admin user</strong> — used once at deployment time by <code class="language-plaintext highlighter-rouge">DatabaseInitializer</code> to create tables and grant privileges</li>
  <li><strong>application user</strong> — used at runtime by <code class="language-plaintext highlighter-rouge">DatabaseSessionManager</code> for all reads and writes</li>
</ul>

<p>Both credentials come from environment variables and map to two separate engine instances: one for schema setup, one for everything else.</p>

<p>This limits the blast radius of a compromised credential: even if an attacker obtains the application user’s password, they cannot drop tables or alter the schema.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this article we covered:</p>

<ul>
  <li>how the <code class="language-plaintext highlighter-rouge">DatabaseSessionManager</code> centralises session lifecycle — commit, rollback, close — so services never manage transactions directly</li>
  <li>how <code class="language-plaintext highlighter-rouge">declarative_base()</code> creates the metadata registry that <code class="language-plaintext highlighter-rouge">create_all</code> relies on</li>
  <li>the DAO vs DTO boundary: DAOs stay inside services, DTOs cross service boundaries — controllers and routes must never handle a raw DAO</li>
  <li>how to define DAO models with relationships, cascades, and composite primary keys, using real examples from the <a href="https://github.com/sasadangelo/runalyze">runalyze</a> project</li>
  <li>how the <code class="language-plaintext highlighter-rouge">DatabaseInitializer</code> service creates the schema at startup in an idempotent, safe way</li>
  <li>when simple ORM queries are enough and when the Repository Pattern earns its place</li>
  <li>why Alembic is the right tool for schema migrations in production</li>
</ul>]]></content><author><name>Salvatore D&apos;Angelo</name></author><category term="Programming" /><category term="programming" /><category term="python" /><summary type="html"><![CDATA[Build a clean, transactional database layer in Python with SQLAlchemy ORM, a DatabaseSessionManager, DAO models, and a clear DAO vs DTO boundary.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://code4projects.org/assets/img/database-layer-with-sqlalchemy-python.jpg" /><media:content medium="image" url="https://code4projects.org/assets/img/database-layer-with-sqlalchemy-python.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Building a Python CLI with Typer: The Modern Approach to the Command Pattern</title><link href="https://code4projects.org/building-a-python-cli-with-typer-the-modern-approach-to-the-command-pattern/" rel="alternate" type="text/html" title="Building a Python CLI with Typer: The Modern Approach to the Command Pattern" /><published>2026-08-11T00:00:00+00:00</published><updated>2026-08-11T00:00:00+00:00</updated><id>https://code4projects.org/building-a-python-cli-with-typer-the-modern-approach-to-the-command-pattern</id><content type="html" xml:base="https://code4projects.org/building-a-python-cli-with-typer-the-modern-approach-to-the-command-pattern/"><![CDATA[<p><img src="/assets/img/building-a-python-cli-with-typer-the-modern-approach-to-the-command-pattern.png" alt="Building a Python CLI with Typer: The Modern Approach to the Command Pattern" width="760" height="400" class="responsive_img" /></p>

<h2 id="introduction">Introduction</h2>

<p>You have already built the same Task Manager CLI twice. In <a href="/building-a-python-cli-with-the-command-pattern/">Part 1</a> you used Python’s built-in <code class="language-plaintext highlighter-rouge">argparse</code> module and the Command Pattern to get a clean, extensible structure. In <a href="/building-a-python-cli-with-click-a-cleaner-approach-to-the-command-pattern/">Part 2</a> you replaced argparse with Click — the code got shorter, type validation became automatic, and the Command Pattern survived without a shared <code class="language-plaintext highlighter-rouge">execute(**kwargs)</code> interface.</p>

<p>Now we do it a third time with <a href="https://typer.tiangolo.com">Typer</a>. Typer turns Python’s own type annotations into a complete CLI — no option decorator stacks, no explicit type parameters, just function signatures. The result is the most readable version of the three, and it is the one I use in my own projects.</p>

<p>You should read this article if:</p>

<ul>
  <li>You have read Parts 1 and 2 and want to see the evolution complete.</li>
  <li>You want a CLI library that is natively type-safe and plays well with mypy and IDE autocompletion.</li>
  <li>You want a real-world reference showing Typer in a real project before committing to it.</li>
</ul>

<h2 id="argparse-vs-click-vs-typer-at-a-glance">argparse vs Click vs Typer at a Glance</h2>

<p>Before diving into code, here is the complete picture:</p>

<table>
  <thead>
    <tr>
      <th><strong>Property</strong></th>
      <th>argparse</th>
      <th>Click</th>
      <th>Typer</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Where it comes from</strong></td>
      <td>Standard library</td>
      <td>Third-party</td>
      <td>Third-party (built on Click)</td>
    </tr>
    <tr>
      <td><strong>How commands are defined</strong></td>
      <td><code class="language-plaintext highlighter-rouge">add_subparsers()</code> + <code class="language-plaintext highlighter-rouge">add_parser()</code></td>
      <td><code class="language-plaintext highlighter-rouge">@click.command</code> / <code class="language-plaintext highlighter-rouge">@click.group</code> decorators</td>
      <td>Annotated function signatures — no option decorators</td>
    </tr>
    <tr>
      <td><strong>Type conversion</strong></td>
      <td>Manual (<code class="language-plaintext highlighter-rouge">type=int</code>)</td>
      <td>Decorator parameter (<code class="language-plaintext highlighter-rouge">type=int</code>)</td>
      <td>Inferred from annotation (<code class="language-plaintext highlighter-rouge">: int</code>)</td>
    </tr>
    <tr>
      <td><strong>Validation on bad input</strong></td>
      <td>Manual</td>
      <td>Automatic, built-in error message</td>
      <td>Automatic, built-in error message</td>
    </tr>
    <tr>
      <td><strong>Sub-apps / command groups</strong></td>
      <td>Nested <code class="language-plaintext highlighter-rouge">add_subparsers()</code></td>
      <td><code class="language-plaintext highlighter-rouge">@click.group</code></td>
      <td><code class="language-plaintext highlighter-rouge">typer.Typer()</code> added via <code class="language-plaintext highlighter-rouge">app.add_typer()</code></td>
    </tr>
    <tr>
      <td><strong><code class="language-plaintext highlighter-rouge">--help</code> generation</strong></td>
      <td>From <code class="language-plaintext highlighter-rouge">help=</code> string</td>
      <td>From <code class="language-plaintext highlighter-rouge">help=</code> string</td>
      <td>From docstring + <code class="language-plaintext highlighter-rouge">help=</code> string</td>
    </tr>
    <tr>
      <td><strong>IDE autocompletion for params</strong></td>
      <td>None</td>
      <td>Partial</td>
      <td>Full — standard type hints</td>
    </tr>
    <tr>
      <td><strong>Lines of code (task add)</strong></td>
      <td>~12</td>
      <td>~4</td>
      <td>~3</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p>Typer is to CLIs what FastAPI is to web APIs: the same idea of driving behaviour from type annotations rather than from imperative configuration.</p>
</blockquote>

<h2 id="why-typer">Why Typer?</h2>

<p>Click removed the argparse boilerplate. Typer removes the option decorator stack.</p>

<p>To be precise: Typer still uses a decorator to register a command (<code class="language-plaintext highlighter-rouge">@app.command()</code>), just like Click. What disappears are all the <code class="language-plaintext highlighter-rouge">@click.option</code> and <code class="language-plaintext highlighter-rouge">@click.argument</code> decorators that pile up above every function in Click. With one option those are manageable; with four or five options the function itself disappears under a tower of decorators.</p>

<p>With Click, every option requires an explicit <code class="language-plaintext highlighter-rouge">@click.option("--name", type=str, required=True)</code> decorator. The type is declared twice — once in the decorator, once in the function signature — and they can silently drift apart. Typer merges the two: the function signature is the definition, and the single <code class="language-plaintext highlighter-rouge">@app.command()</code> decorator is the only one you need.</p>

<p>Two more practical gains:</p>

<ol>
  <li><strong>Docstrings become help text.</strong> Write <code class="language-plaintext highlighter-rouge">"""Deploy a new agent sandbox."""</code> on a function and Typer uses it for <code class="language-plaintext highlighter-rouge">--help</code>. You document the code and the CLI at the same time.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">Optional</code> and <code class="language-plaintext highlighter-rouge">Annotated</code> work as you expect.</strong> <code class="language-plaintext highlighter-rouge">name: str | None = None</code> is an optional option. <code class="language-plaintext highlighter-rouge">Annotated[str, typer.Option(help="Task name")]</code> adds metadata without a separate decorator.</li>
</ol>

<p>Typer is built on top of Click, so every Click feature is still available if you need it. You are not giving anything up.</p>

<h2 id="installing-typer">Installing Typer</h2>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uv add <span class="s2">"typer[all]"</span>
</code></pre></div></div>

<p>Breaking down the options:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">typer</code> — the core library</li>
  <li><code class="language-plaintext highlighter-rouge">[all]</code> — installs <code class="language-plaintext highlighter-rouge">rich</code> (coloured output, formatted help pages) and <code class="language-plaintext highlighter-rouge">shellingham</code> (shell auto-completion detection); highly recommended for end-user CLIs</li>
</ul>

<p>Running <code class="language-plaintext highlighter-rouge">uv add</code> records the dependency in <code class="language-plaintext highlighter-rouge">pyproject.toml</code> and updates <code class="language-plaintext highlighter-rouge">uv.lock</code> — no manual editing needed.</p>

<h2 id="the-task-manager-cli--code-walkthrough">The Task Manager CLI — Code Walkthrough</h2>

<p>The CLI syntax stays the same as in Parts 1 and 2:</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python3 cli.py task add <span class="nt">--name</span> <span class="s2">"Buy milk"</span>
python3 cli.py task list
python3 cli.py task delete <span class="nt">--id</span> 1
python3 cli.py stats summary
python3 cli.py stats <span class="nb">export</span> <span class="nt">--output</span> report.csv
</code></pre></div></div>

<p>No external repository this time — all code lives inline in this article.</p>

<h3 id="project-structure">Project Structure</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>task-cli/
├── cli.py
└── commands/
    ├── __init__.py
    ├── base.py
    ├── add_task_command.py
    ├── list_task_command.py
    ├── delete_task_command.py
    └── stats_command.py
</code></pre></div></div>

<p>The structure is identical to Parts 1 and 2. Only <code class="language-plaintext highlighter-rouge">cli.py</code> changes significantly.</p>

<h3 id="the-base-command-class">The Base Command Class</h3>

<p>Same marker pattern introduced in the Click version — no shared <code class="language-plaintext highlighter-rouge">execute()</code> method, just a semantic boundary:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># commands/base.py
</span><span class="kn">from</span> <span class="n">abc</span> <span class="kn">import</span> <span class="n">ABC</span>


<span class="k">class</span> <span class="nc">Command</span><span class="p">(</span><span class="n">ABC</span><span class="p">):</span>
    <span class="sh">"""</span><span class="s">Marker base class for CLI commands.</span><span class="sh">"""</span>
</code></pre></div></div>

<h3 id="the-concrete-command-classes">The Concrete Command Classes</h3>

<p>The command classes are unchanged from the Click version. Each method carries an explicit, typed signature:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># commands/add_task_command.py
</span><span class="kn">from</span> <span class="n">.base</span> <span class="kn">import</span> <span class="n">Command</span>


<span class="k">class</span> <span class="nc">AddTaskCommand</span><span class="p">(</span><span class="n">Command</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">execute</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">name</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="k">with</span> <span class="nf">open</span><span class="p">(</span><span class="sh">"</span><span class="s">tasks.txt</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">a</span><span class="sh">"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
            <span class="n">f</span><span class="p">.</span><span class="nf">write</span><span class="p">(</span><span class="n">name</span> <span class="o">+</span> <span class="sh">"</span><span class="se">\n</span><span class="sh">"</span><span class="p">)</span>
        <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Task added: </span><span class="si">{</span><span class="n">name</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># commands/list_task_command.py
</span><span class="kn">from</span> <span class="n">.base</span> <span class="kn">import</span> <span class="n">Command</span>


<span class="k">class</span> <span class="nc">ListTaskCommand</span><span class="p">(</span><span class="n">Command</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">execute</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="k">try</span><span class="p">:</span>
            <span class="k">with</span> <span class="nf">open</span><span class="p">(</span><span class="sh">"</span><span class="s">tasks.txt</span><span class="sh">"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
                <span class="n">tasks</span> <span class="o">=</span> <span class="p">[</span><span class="n">line</span><span class="p">.</span><span class="nf">strip</span><span class="p">()</span> <span class="k">for</span> <span class="n">line</span> <span class="ow">in</span> <span class="n">f</span> <span class="k">if</span> <span class="n">line</span><span class="p">.</span><span class="nf">strip</span><span class="p">()]</span>
        <span class="k">except</span> <span class="nb">FileNotFoundError</span><span class="p">:</span>
            <span class="n">tasks</span> <span class="o">=</span> <span class="p">[]</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="n">tasks</span><span class="p">:</span>
            <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">No tasks found.</span><span class="sh">"</span><span class="p">)</span>
            <span class="k">return</span>
        <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">Tasks:</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">t</span> <span class="ow">in</span> <span class="nf">enumerate</span><span class="p">(</span><span class="n">tasks</span><span class="p">,</span> <span class="mi">1</span><span class="p">):</span>
            <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">i</span><span class="si">}</span><span class="s">. </span><span class="si">{</span><span class="n">t</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># commands/delete_task_command.py
</span><span class="kn">from</span> <span class="n">.base</span> <span class="kn">import</span> <span class="n">Command</span>


<span class="k">class</span> <span class="nc">DeleteTaskCommand</span><span class="p">(</span><span class="n">Command</span><span class="p">):</span>
    <span class="k">def</span> <span class="nf">execute</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">task_id</span><span class="p">:</span> <span class="nb">int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="k">try</span><span class="p">:</span>
            <span class="k">with</span> <span class="nf">open</span><span class="p">(</span><span class="sh">"</span><span class="s">tasks.txt</span><span class="sh">"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
                <span class="n">tasks</span> <span class="o">=</span> <span class="p">[</span><span class="n">line</span><span class="p">.</span><span class="nf">strip</span><span class="p">()</span> <span class="k">for</span> <span class="n">line</span> <span class="ow">in</span> <span class="n">f</span> <span class="k">if</span> <span class="n">line</span><span class="p">.</span><span class="nf">strip</span><span class="p">()]</span>
        <span class="k">except</span> <span class="nb">FileNotFoundError</span><span class="p">:</span>
            <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">No tasks found.</span><span class="sh">"</span><span class="p">)</span>
            <span class="k">return</span>
        <span class="k">if</span> <span class="n">task_id</span> <span class="o">&lt;</span> <span class="mi">1</span> <span class="ow">or</span> <span class="n">task_id</span> <span class="o">&gt;</span> <span class="nf">len</span><span class="p">(</span><span class="n">tasks</span><span class="p">):</span>
            <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Invalid task ID: </span><span class="si">{</span><span class="n">task_id</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
            <span class="k">return</span>
        <span class="n">removed</span> <span class="o">=</span> <span class="n">tasks</span><span class="p">.</span><span class="nf">pop</span><span class="p">(</span><span class="n">task_id</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span>
        <span class="k">with</span> <span class="nf">open</span><span class="p">(</span><span class="sh">"</span><span class="s">tasks.txt</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">w</span><span class="sh">"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
            <span class="n">f</span><span class="p">.</span><span class="nf">write</span><span class="p">(</span><span class="sh">"</span><span class="se">\n</span><span class="sh">"</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">tasks</span><span class="p">))</span>
        <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Task deleted: </span><span class="si">{</span><span class="n">removed</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># commands/stats_command.py
</span><span class="kn">import</span> <span class="n">csv</span>
<span class="kn">import</span> <span class="n">os</span>
<span class="kn">from</span> <span class="n">.base</span> <span class="kn">import</span> <span class="n">Command</span>


<span class="k">class</span> <span class="nc">StatsCommand</span><span class="p">(</span><span class="n">Command</span><span class="p">):</span>
    <span class="n">TASKS_FILE</span> <span class="o">=</span> <span class="sh">"</span><span class="s">tasks.txt</span><span class="sh">"</span>

    <span class="k">def</span> <span class="nf">summary</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">tasks</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">_load_tasks</span><span class="p">()</span>
        <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">You have </span><span class="si">{</span><span class="nf">len</span><span class="p">(</span><span class="n">tasks</span><span class="p">)</span><span class="si">}</span><span class="s"> task(s).</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">t</span> <span class="ow">in</span> <span class="nf">enumerate</span><span class="p">(</span><span class="n">tasks</span><span class="p">,</span> <span class="mi">1</span><span class="p">):</span>
            <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">i</span><span class="si">}</span><span class="s">. </span><span class="si">{</span><span class="n">t</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>

    <span class="k">def</span> <span class="nf">export</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">output</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">tasks</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">_load_tasks</span><span class="p">()</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="n">tasks</span><span class="p">:</span>
            <span class="nf">print</span><span class="p">(</span><span class="sh">"</span><span class="s">No tasks to export.</span><span class="sh">"</span><span class="p">)</span>
            <span class="k">return</span>
        <span class="k">with</span> <span class="nf">open</span><span class="p">(</span><span class="n">output</span><span class="p">,</span> <span class="sh">"</span><span class="s">w</span><span class="sh">"</span><span class="p">,</span> <span class="n">newline</span><span class="o">=</span><span class="sh">""</span><span class="p">)</span> <span class="k">as</span> <span class="n">csvfile</span><span class="p">:</span>
            <span class="n">writer</span> <span class="o">=</span> <span class="n">csv</span><span class="p">.</span><span class="nf">writer</span><span class="p">(</span><span class="n">csvfile</span><span class="p">)</span>
            <span class="n">writer</span><span class="p">.</span><span class="nf">writerow</span><span class="p">([</span><span class="sh">"</span><span class="s">ID</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Task</span><span class="sh">"</span><span class="p">])</span>
            <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">task</span> <span class="ow">in</span> <span class="nf">enumerate</span><span class="p">(</span><span class="n">tasks</span><span class="p">,</span> <span class="mi">1</span><span class="p">):</span>
                <span class="n">writer</span><span class="p">.</span><span class="nf">writerow</span><span class="p">([</span><span class="n">i</span><span class="p">,</span> <span class="n">task</span><span class="p">])</span>
        <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Tasks exported to </span><span class="si">{</span><span class="n">output</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>

    <span class="k">def</span> <span class="nf">_load_tasks</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]:</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="nf">exists</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">TASKS_FILE</span><span class="p">):</span>
            <span class="k">return</span> <span class="p">[]</span>
        <span class="k">with</span> <span class="nf">open</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">TASKS_FILE</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
            <span class="k">return</span> <span class="p">[</span><span class="n">line</span><span class="p">.</span><span class="nf">strip</span><span class="p">()</span> <span class="k">for</span> <span class="n">line</span> <span class="ow">in</span> <span class="n">f</span> <span class="k">if</span> <span class="n">line</span><span class="p">.</span><span class="nf">strip</span><span class="p">()]</span>
</code></pre></div></div>

<h3 id="the-cli-entry-point">The CLI Entry Point</h3>

<p>This is where Typer replaces Click. Compare the structure to the Click version and notice what disappears: no <code class="language-plaintext highlighter-rouge">@click.option</code> decorator stacks, no explicit <code class="language-plaintext highlighter-rouge">type=</code> parameters.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># cli.py
</span><span class="kn">from</span> <span class="n">typing</span> <span class="kn">import</span> <span class="n">Annotated</span>
<span class="kn">import</span> <span class="n">typer</span>
<span class="kn">from</span> <span class="n">commands.add_task_command</span> <span class="kn">import</span> <span class="n">AddTaskCommand</span>
<span class="kn">from</span> <span class="n">commands.list_task_command</span> <span class="kn">import</span> <span class="n">ListTaskCommand</span>
<span class="kn">from</span> <span class="n">commands.delete_task_command</span> <span class="kn">import</span> <span class="n">DeleteTaskCommand</span>
<span class="kn">from</span> <span class="n">commands.stats_command</span> <span class="kn">import</span> <span class="n">StatsCommand</span>

<span class="n">app</span> <span class="o">=</span> <span class="n">typer</span><span class="p">.</span><span class="nc">Typer</span><span class="p">(</span><span class="nb">help</span><span class="o">=</span><span class="sh">"</span><span class="s">Task Manager CLI</span><span class="sh">"</span><span class="p">,</span> <span class="n">no_args_is_help</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
<span class="n">task_app</span> <span class="o">=</span> <span class="n">typer</span><span class="p">.</span><span class="nc">Typer</span><span class="p">(</span><span class="nb">help</span><span class="o">=</span><span class="sh">"</span><span class="s">Manage tasks.</span><span class="sh">"</span><span class="p">,</span> <span class="n">no_args_is_help</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
<span class="n">stats_app</span> <span class="o">=</span> <span class="n">typer</span><span class="p">.</span><span class="nc">Typer</span><span class="p">(</span><span class="nb">help</span><span class="o">=</span><span class="sh">"</span><span class="s">Show or export task statistics.</span><span class="sh">"</span><span class="p">,</span> <span class="n">no_args_is_help</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>

<span class="n">app</span><span class="p">.</span><span class="nf">add_typer</span><span class="p">(</span><span class="n">task_app</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="sh">"</span><span class="s">task</span><span class="sh">"</span><span class="p">)</span>
<span class="n">app</span><span class="p">.</span><span class="nf">add_typer</span><span class="p">(</span><span class="n">stats_app</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="sh">"</span><span class="s">stats</span><span class="sh">"</span><span class="p">)</span>


<span class="nd">@task_app.command</span><span class="p">(</span><span class="sh">"</span><span class="s">add</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">task_add</span><span class="p">(</span>
    <span class="n">name</span><span class="p">:</span> <span class="n">Annotated</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">typer</span><span class="p">.</span><span class="nc">Option</span><span class="p">(</span><span class="sh">"</span><span class="s">--name</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">-n</span><span class="sh">"</span><span class="p">,</span> <span class="nb">help</span><span class="o">=</span><span class="sh">"</span><span class="s">Task name.</span><span class="sh">"</span><span class="p">)],</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="sh">"""</span><span class="s">Add a new task.</span><span class="sh">"""</span>
    <span class="nc">AddTaskCommand</span><span class="p">().</span><span class="nf">execute</span><span class="p">(</span><span class="n">name</span><span class="o">=</span><span class="n">name</span><span class="p">)</span>


<span class="nd">@task_app.command</span><span class="p">(</span><span class="sh">"</span><span class="s">list</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">task_list</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="sh">"""</span><span class="s">List all tasks.</span><span class="sh">"""</span>
    <span class="nc">ListTaskCommand</span><span class="p">().</span><span class="nf">execute</span><span class="p">()</span>


<span class="nd">@task_app.command</span><span class="p">(</span><span class="sh">"</span><span class="s">delete</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">task_delete</span><span class="p">(</span>
    <span class="n">task_id</span><span class="p">:</span> <span class="n">Annotated</span><span class="p">[</span><span class="nb">int</span><span class="p">,</span> <span class="n">typer</span><span class="p">.</span><span class="nc">Option</span><span class="p">(</span><span class="sh">"</span><span class="s">--id</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">-i</span><span class="sh">"</span><span class="p">,</span> <span class="nb">help</span><span class="o">=</span><span class="sh">"</span><span class="s">Task ID to delete.</span><span class="sh">"</span><span class="p">)],</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="sh">"""</span><span class="s">Delete a task by ID.</span><span class="sh">"""</span>
    <span class="nc">DeleteTaskCommand</span><span class="p">().</span><span class="nf">execute</span><span class="p">(</span><span class="n">task_id</span><span class="o">=</span><span class="n">task_id</span><span class="p">)</span>


<span class="nd">@stats_app.command</span><span class="p">(</span><span class="sh">"</span><span class="s">summary</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">stats_summary</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="sh">"""</span><span class="s">Show a summary of all tasks.</span><span class="sh">"""</span>
    <span class="nc">StatsCommand</span><span class="p">().</span><span class="nf">summary</span><span class="p">()</span>


<span class="nd">@stats_app.command</span><span class="p">(</span><span class="sh">"</span><span class="s">export</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">stats_export</span><span class="p">(</span>
    <span class="n">output</span><span class="p">:</span> <span class="n">Annotated</span><span class="p">[</span>
        <span class="nb">str</span><span class="p">,</span> <span class="n">typer</span><span class="p">.</span><span class="nc">Option</span><span class="p">(</span><span class="sh">"</span><span class="s">--output</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">-o</span><span class="sh">"</span><span class="p">,</span> <span class="nb">help</span><span class="o">=</span><span class="sh">"</span><span class="s">Output CSV file name.</span><span class="sh">"</span><span class="p">)</span>
    <span class="p">]</span> <span class="o">=</span> <span class="sh">"</span><span class="s">tasks.csv</span><span class="sh">"</span><span class="p">,</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="sh">"""</span><span class="s">Export tasks to a CSV file.</span><span class="sh">"""</span>
    <span class="nc">StatsCommand</span><span class="p">().</span><span class="nf">export</span><span class="p">(</span><span class="n">output</span><span class="o">=</span><span class="n">output</span><span class="p">)</span>


<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="sh">"</span><span class="s">__main__</span><span class="sh">"</span><span class="p">:</span>
    <span class="nf">app</span><span class="p">()</span>
</code></pre></div></div>

<p>A few things worth noting:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">app.add_typer(task_app, name="task")</code> registers a sub-app — the equivalent of <code class="language-plaintext highlighter-rouge">@click.group</code>. Each sub-app is an independent <code class="language-plaintext highlighter-rouge">typer.Typer()</code> instance.</li>
  <li><code class="language-plaintext highlighter-rouge">Annotated[str, typer.Option(...)]</code> is the idiomatic Typer way to attach help text and flags to a parameter while keeping the type annotation separate and readable by mypy.</li>
  <li>The function docstring (<code class="language-plaintext highlighter-rouge">"""Add a new task."""</code>) becomes the command description in <code class="language-plaintext highlighter-rouge">--help</code> automatically.</li>
  <li><code class="language-plaintext highlighter-rouge">no_args_is_help=True</code> on each app means running <code class="language-plaintext highlighter-rouge">python3 cli.py</code> or <code class="language-plaintext highlighter-rouge">python3 cli.py task</code> without a subcommand prints the help page instead of doing nothing.</li>
</ul>

<h3 id="running-the-cli">Running the CLI</h3>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python3 cli.py task add <span class="nt">--name</span> <span class="s2">"Write Part 3"</span>
python3 cli.py task add <span class="nt">--name</span> <span class="s2">"Review PR"</span>
python3 cli.py task list
python3 cli.py task delete <span class="nt">--id</span> 1
python3 cli.py stats summary
python3 cli.py stats <span class="nb">export</span> <span class="nt">--output</span> report.csv
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">--help</code> output for the top-level app:</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python3 cli.py <span class="nt">--help</span>
</code></pre></div></div>

<p>Typer renders a rich, coloured help page listing all registered sub-apps and commands — at no extra cost.</p>

<h2 id="side-by-side-the-same-command-in-three-libraries">Side-by-Side: the Same Command in Three Libraries</h2>

<p>To make the progression concrete, here is <code class="language-plaintext highlighter-rouge">task add</code> implemented in all three:</p>

<p><strong>argparse (Part 1)</strong></p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">add_parser</span> <span class="o">=</span> <span class="n">subparsers</span><span class="p">.</span><span class="nf">add_parser</span><span class="p">(</span><span class="sh">"</span><span class="s">add</span><span class="sh">"</span><span class="p">,</span> <span class="nb">help</span><span class="o">=</span><span class="sh">"</span><span class="s">Add a new task</span><span class="sh">"</span><span class="p">)</span>
<span class="n">add_parser</span><span class="p">.</span><span class="nf">add_argument</span><span class="p">(</span><span class="sh">"</span><span class="s">--name</span><span class="sh">"</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="nb">help</span><span class="o">=</span><span class="sh">"</span><span class="s">Task name</span><span class="sh">"</span><span class="p">)</span>
<span class="n">add_parser</span><span class="p">.</span><span class="nf">set_defaults</span><span class="p">(</span><span class="n">func</span><span class="o">=</span><span class="n">self</span><span class="p">.</span><span class="n">commands</span><span class="p">[</span><span class="sh">"</span><span class="s">add</span><span class="sh">"</span><span class="p">].</span><span class="n">execute</span><span class="p">)</span>
</code></pre></div></div>

<p><strong>Click (Part 2)</strong></p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@cli.command</span><span class="p">(</span><span class="nb">help</span><span class="o">=</span><span class="sh">"</span><span class="s">Add a new task</span><span class="sh">"</span><span class="p">)</span>
<span class="nd">@click.option</span><span class="p">(</span><span class="sh">"</span><span class="s">--name</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">-n</span><span class="sh">"</span><span class="p">,</span> <span class="n">required</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="nb">help</span><span class="o">=</span><span class="sh">"</span><span class="s">Task name</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">add</span><span class="p">(</span><span class="n">name</span><span class="p">):</span>
    <span class="nc">AddTaskCommand</span><span class="p">().</span><span class="nf">execute</span><span class="p">(</span><span class="n">name</span><span class="o">=</span><span class="n">name</span><span class="p">)</span>
</code></pre></div></div>

<p><strong>Typer (this article)</strong></p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@task_app.command</span><span class="p">(</span><span class="sh">"</span><span class="s">add</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">task_add</span><span class="p">(</span>
    <span class="n">name</span><span class="p">:</span> <span class="n">Annotated</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">typer</span><span class="p">.</span><span class="nc">Option</span><span class="p">(</span><span class="sh">"</span><span class="s">--name</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">-n</span><span class="sh">"</span><span class="p">,</span> <span class="nb">help</span><span class="o">=</span><span class="sh">"</span><span class="s">Task name.</span><span class="sh">"</span><span class="p">)],</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="sh">"""</span><span class="s">Add a new task.</span><span class="sh">"""</span>
    <span class="nc">AddTaskCommand</span><span class="p">().</span><span class="nf">execute</span><span class="p">(</span><span class="n">name</span><span class="o">=</span><span class="n">name</span><span class="p">)</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th><strong>Property</strong></th>
      <th>argparse</th>
      <th>Click</th>
      <th>Typer</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Lines for <code class="language-plaintext highlighter-rouge">task add</code></strong></td>
      <td>3</td>
      <td>4 (2 decorators + function)</td>
      <td>3 (1 decorator + function)</td>
    </tr>
    <tr>
      <td><strong>Type declared explicitly</strong></td>
      <td><code class="language-plaintext highlighter-rouge">type=str</code> (default)</td>
      <td><code class="language-plaintext highlighter-rouge">type=str</code> (default)</td>
      <td>Inferred from <code class="language-plaintext highlighter-rouge">: str</code></td>
    </tr>
    <tr>
      <td><strong>Help text location</strong></td>
      <td><code class="language-plaintext highlighter-rouge">help=</code> in <code class="language-plaintext highlighter-rouge">add_argument</code></td>
      <td><code class="language-plaintext highlighter-rouge">help=</code> in <code class="language-plaintext highlighter-rouge">@click.option</code></td>
      <td>Function docstring</td>
    </tr>
    <tr>
      <td><strong>Required enforced by</strong></td>
      <td><code class="language-plaintext highlighter-rouge">required=True</code></td>
      <td><code class="language-plaintext highlighter-rouge">required=True</code></td>
      <td>No default value present</td>
    </tr>
    <tr>
      <td><strong>mypy compatibility</strong></td>
      <td>Low — <code class="language-plaintext highlighter-rouge">args.name</code> is <code class="language-plaintext highlighter-rouge">Any</code></td>
      <td>Medium — decorator type and signature can drift</td>
      <td>High — annotation is the single source of truth</td>
    </tr>
  </tbody>
</table>

<h2 id="a-real-world-reference-golem-cli">A Real-World Reference: golem-cli</h2>

<p>The pattern above is not just a toy example. <a href="https://github.com/sasadangelo/golem-cli">golem-cli</a> is the CLI client for a real Agentic AI Platform-as-a-Service project I am working on, built with exactly this structure.</p>

<p>The <code class="language-plaintext highlighter-rouge">cli.py</code> entry point is pure Typer wiring — no business logic:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># src/golem_cli/cli.py (excerpt)
</span><span class="n">app</span> <span class="o">=</span> <span class="n">typer</span><span class="p">.</span><span class="nc">Typer</span><span class="p">(</span>
    <span class="n">name</span><span class="o">=</span><span class="sh">"</span><span class="s">golem</span><span class="sh">"</span><span class="p">,</span> <span class="nb">help</span><span class="o">=</span><span class="sh">"</span><span class="s">golem — Golem Agent-as-a-Service CLI</span><span class="sh">"</span><span class="p">,</span> <span class="n">no_args_is_help</span><span class="o">=</span><span class="bp">True</span>
<span class="p">)</span>

<span class="n">agent_app</span> <span class="o">=</span> <span class="n">typer</span><span class="p">.</span><span class="nc">Typer</span><span class="p">(</span><span class="nb">help</span><span class="o">=</span><span class="sh">"</span><span class="s">Manage agent sandboxes.</span><span class="sh">"</span><span class="p">,</span> <span class="n">no_args_is_help</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
<span class="n">app</span><span class="p">.</span><span class="nf">add_typer</span><span class="p">(</span><span class="n">agent_app</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="sh">"</span><span class="s">agent</span><span class="sh">"</span><span class="p">)</span>


<span class="nd">@agent_app.command</span><span class="p">(</span><span class="sh">"</span><span class="s">create</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">agent_create</span><span class="p">(</span>
    <span class="n">config</span><span class="p">:</span> <span class="n">Annotated</span><span class="p">[</span>
        <span class="n">Path</span><span class="p">,</span> <span class="n">typer</span><span class="p">.</span><span class="nc">Option</span><span class="p">(</span><span class="sh">"</span><span class="s">--config</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">-c</span><span class="sh">"</span><span class="p">,</span> <span class="nb">help</span><span class="o">=</span><span class="sh">"</span><span class="s">Path to the runner config YAML.</span><span class="sh">"</span><span class="p">)</span>
    <span class="p">],</span>
    <span class="n">ttl</span><span class="p">:</span> <span class="n">Annotated</span><span class="p">[</span>
        <span class="nb">int</span><span class="p">,</span> <span class="n">typer</span><span class="p">.</span><span class="nc">Option</span><span class="p">(</span><span class="sh">"</span><span class="s">--ttl</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">-t</span><span class="sh">"</span><span class="p">,</span> <span class="nb">help</span><span class="o">=</span><span class="sh">"</span><span class="s">Sandbox time-to-live in seconds.</span><span class="sh">"</span><span class="p">)</span>
    <span class="p">]</span> <span class="o">=</span> <span class="mi">3600</span><span class="p">,</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
    <span class="sh">"""</span><span class="s">Deploy a new agent sandbox.</span><span class="sh">"""</span>
    <span class="nc">AgentCommand</span><span class="p">().</span><span class="nf">create</span><span class="p">(</span><span class="n">config</span><span class="o">=</span><span class="n">config</span><span class="p">,</span> <span class="n">ttl_seconds</span><span class="o">=</span><span class="n">ttl</span><span class="p">)</span>
</code></pre></div></div>

<p>The business logic lives entirely in <code class="language-plaintext highlighter-rouge">AgentCommand</code>, <code class="language-plaintext highlighter-rouge">ChatCommand</code>, and <code class="language-plaintext highlighter-rouge">CpCommand</code> — the Command Pattern is preserved cleanly. The CLI file’s only job is to map flags to method calls.</p>

<p>The project structure mirrors what you built in this series:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>golem-cli/
└── src/
    └── golem_cli/
        ├── cli.py              ← Typer wiring only
        └── commands/
            ├── base.py         ← Marker ABC
            ├── agent_command.py
            ├── chat_command.py
            └── cp_command.py
</code></pre></div></div>

<p>When Typer is your chosen library, this is what a real project looks like.</p>

<h2 id="when-to-keep-using-click">When to Keep Using Click</h2>

<p>Typer is the better default for new projects, but Click still wins in specific cases:</p>

<ul>
  <li><strong>You are extending an existing Click-based codebase.</strong> Typer can wrap Click objects, but mixing them adds complexity — stay pure.</li>
  <li><strong>You need Click plugins or third-party Click extensions.</strong> The ecosystem is built around Click’s decorator API.</li>
  <li><strong>You need fine-grained control over context objects and pass-through behaviour.</strong> Click’s <code class="language-plaintext highlighter-rouge">@click.pass_context</code> is more explicit for advanced orchestration.</li>
</ul>

<p>For everything else — new projects, in-house tools, developer utilities — Typer’s type-hint–driven approach is cleaner and safer.</p>

<h2 id="conclusion">Conclusion</h2>

<p>In this article we covered:</p>

<ul>
  <li>Why Typer eliminates the decorator boilerplate that Click still requires, by inferring command structure from type annotations</li>
  <li>How to build a complete Task Manager CLI with Typer: sub-apps, typed options, docstring-driven help text</li>
  <li>How the same Command Pattern from Parts 1 and 2 carries over unchanged — business logic in command classes, wiring in <code class="language-plaintext highlighter-rouge">cli.py</code></li>
  <li>A side-by-side comparison of <code class="language-plaintext highlighter-rouge">argparse</code>, Click, and Typer on the same command, with a concrete table of differences</li>
  <li>How <code class="language-plaintext highlighter-rouge">golem-cli</code> applies this exact pattern in a real project</li>
</ul>

<p>This is the final article in the <a href="/how-to-set-up-your-next-python-project/">Modern Python Application</a> series. The three CLI posts together form a complete toolkit: argparse for zero-dependency scripts, Click for mature ecosystems, Typer for new projects where type safety and readability matter. For a deeper dive into the Command Pattern used throughout, the <a href="https://refactoring.guru/design-patterns/command">Refactoring.Guru reference</a> is still the best starting point.</p>]]></content><author><name>Salvatore D&apos;Angelo</name></author><category term="Programming" /><category term="programming" /><category term="python" /><summary type="html"><![CDATA[Typer brings type hints to Python CLIs: no decorators, no boilerplate. See how it compares to argparse and Click on the same Task Manager CLI.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://code4projects.org/assets/img/building-a-python-cli-with-typer-the-modern-approach-to-the-command-pattern.png" /><media:content medium="image" url="https://code4projects.org/assets/img/building-a-python-cli-with-typer-the-modern-approach-to-the-command-pattern.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>