<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[David Montesdeoca]]></title><description><![CDATA[Passionate software engineer and continuous learner]]></description><link>https://davidmontesdeoca.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 11 Sep 2026 04:21:59 GMT</lastBuildDate><atom:link href="https://davidmontesdeoca.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[The one about my experience at SNGULAR (year 2)]]></title><description><![CDATA[It has now been two years since I joined SNGULAR. Just as I did last year when I completed my first year, I want to talk about what my experience with the company has been like during this second year]]></description><link>https://davidmontesdeoca.dev/the-one-about-my-experience-at-sngular-year-2</link><guid isPermaLink="true">https://davidmontesdeoca.dev/the-one-about-my-experience-at-sngular-year-2</guid><category><![CDATA[sngular]]></category><category><![CDATA[consulting]]></category><category><![CDATA[consulting firm]]></category><category><![CDATA[Consultancy]]></category><dc:creator><![CDATA[David Montesdeoca]]></dc:creator><pubDate>Fri, 21 Aug 2026 17:24:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/642d433eeaad3d174f737099/e546c5ca-b9f7-4331-a0d3-352061a7e11a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>It has now been two years since I joined <a href="https://www.sngular.com/">SNGULAR</a>. Just as I did last year when I completed my first year, I want to talk about what my experience with the company has been like during this second year.</p>
<p>Since SNGULAR is a consulting firm and I work full-time for a client, the truth is that my <a href="https://davidmontesdeoca.dev/the-one-about-my-experience-at-sngular-so-far#relationship-with-the-company">relationship with the company itself</a> has not changed much compared to what I mentioned last year.</p>
<p>But a year is a long time, and things always happen.</p>
<h2>Change of area within the company</h2>
<p>The group of engineers working for the same client was moved to SNGULAR's services division at the end of last year. That shift also brought a change in manager for us, as well as a new liaison with the client — roles that had previously been handled by the same person. It was a completely unexpected change for all of us.</p>
<p>What surprised us even more was that the person assigned to replace our manager was scheduled to go on maternity leave in just a couple of months. That means we now have another manager replacing the first replacement, plus a new person acting as liaison with the client.</p>
<p>I have to admit it has not made much of a difference for us on a day-to-day basis. Usually, in our weekly meetings, they just tell us there are no updates from either SNGULAR or the client.</p>
<h2>New security policy</h2>
<p>This is the biggest change that occurred over the past year, and one that generated quite a bit of controversy.</p>
<p>At the beginning of 2026, the IT team notified us that, within a few months, they would be adding new security layers to restrict which devices can connect to internal company tools in order to comply with certain certifications. From that moment on, we could only access these tools from the laptop provided by the company. This includes things like checking email and Slack, joining the weekly meeting with colleagues assigned to the same client, logging project hours, requesting time off, and so on.</p>
<p>This measure affected us in a phased rollout. I was among the first affected, starting in early April, while some of my colleagues were not impacted until well into June.</p>
<p>In practice, we are forced to keep two laptops running on our desks at all times. Before this, it was very convenient to use just one computer and set up a separate browser profile to access the SNGULAR tools we needed.</p>
<p>It is true that they give us the option to ask the IT team to set up an additional device to access those tools, but under no circumstances can that device be the client's laptop.</p>
<p>In my case, to avoid having two laptops constantly turned on, I requested access from my Android phone. To do that, I had to install an app that sets up a work profile and contact the IT team to add the necessary configuration. Now, I just enable that profile whenever I need to check email or anything else. The rest of the time, the profile stays disabled:</p>
<img width="1080" height="2115" alt="Image" src="https://github.com/user-attachments/assets/54ef5da9-4154-42d5-9a1c-4a23ae16e943" />

<p>It works for me, but having software installed on your personal mobile device just because of work-imposed restrictions is far from ideal. In fact, most of my colleagues refuse to install that software altogether.</p>
<p>Our manager is aware that response times will be significantly slower than usual due to this constraint. We have complained to the IT team on multiple occasions, but they tell us there is nothing more they can do.</p>
<h2>Salary review</h2>
<p>This topic also caused quite a bit of controversy. My colleagues had already warned me that salary raises at SNGULAR are modest, to put it politely.</p>
<p>Every time the subject has come up so far, we have been told that the company's revenue from the previous year was lower than expected, so the budget allocated for raises was less than they would have liked.</p>
<p>I already <a href="https://davidmontesdeoca.dev/the-one-about-my-experience-at-sngular-so-far#disadvantages">mentioned</a> last year that I was not eligible for a salary review because I had not met the minimum tenure requirement at the company when the reviews took place.</p>
<p>However, my previous manager promised that there would be a salary raise for me this year, considering that I deserved one last year and only missed out due to a technicality.</p>
<p>Naturally, salary reviews depend heavily on the previous year's performance evaluation. In my case, the evaluation is done by another engineer on the SNGULAR team who works for the same client. He assured me he would fight for a fair raise for me and expected at least a 5% increase, though he ultimately did not have the final say.</p>
<p>When the time came, it was the temporary manager who broke the news about the raise they considered fair for me. In this case, they felt 2% was appropriate, but — in her exact words — since that was a rather laughable figure, they bumped it up to a little over 3% to make it a round number. The raise would take effect the following month.</p>
<p>It was a disappointment for me, and it left a bad taste in my mouth that she tried to sell it as if they were doing me a favor.</p>
<p>They gave me the option to submit a formal appeal to reconsider my case. Although the outcome was obvious — as my salary remained unchanged — I never received an official response from the company regarding my appeal, either before or after the manager went on leave.</p>
<p>Later, I found out that some of my colleagues initially received no raise at all, although after filing an appeal, the company apparently reconsidered and granted them a raise.</p>
<p>From what I gather, that 2-3% raise is the company standard when performance in the engineering team is good, but I felt the promise made to me the previous year was not kept. What they did fulfill was a promotion in my professional title, moving from a Senior role to an Expert role. Granted, I was already at the top of the Senior salary bracket, so any salary raise required a title bump beforehand anyway.</p>
<h2>Other details to highlight</h2>
<p>Before wrapping up the review of this second year, I would like to quickly touch on a few other details:</p>
<ul>
<li>This time, no one from HR reached out to me personally; I simply received an automated email congratulating me on my two-year anniversary with the company.</li>
<li>I still have monthly 1-on-1 meetings with the engineer acting as manager for those of us working with this client, which always helps to maintain some connection.</li>
<li>This year the company did not organize a summer party, but they did send us a Christmas hamper. The previous year, they made a donation in our name without consulting us and had to backtrack following understandable employee complaints, ultimately paying us the corresponding money in the next paycheck.</li>
<li>The policy regarding the training and wellness budgets remains as strict as ever. We still cannot use it for things we actually consider necessary for our daily work, like a standing desk.</li>
</ul>
<h2>Conclusion</h2>
<p>If I had to sum up this second year, I would say it has been a reality check. The friction over new security policies, the disappointments regarding salary reviews, and the bureaucracy are constant reminders that, at the end of the day, you work for a consulting firm — with everything that entails.</p>
<p>However, my day-to-day work remains 100% focused on the client, and that is where I find the stability, technical challenges, and good team atmosphere that make the job worthwhile. As I mentioned last year, in this line of work, your overall satisfaction depends almost exclusively on the nature of your assigned project.</p>
<p>Despite the things I would certainly improve in my relationship with SNGULAR, the overall balance remains positive thanks to my day-to-day working environment. We will see what year three brings.</p>
<p>Thanks for reading and see you in the next one!</p>
]]></content:encoded></item><item><title><![CDATA[The one where backups keep your latest posts on your GitHub profile up to date]]></title><description><![CDATA[A while ago I wrote a post about automatically adding my latest blog posts to my GitHub profile. The idea was simple: a GitHub Action would query Hashnode's public GraphQL API, grab my most recent pos]]></description><link>https://davidmontesdeoca.dev/the-one-where-backups-keep-your-latest-posts-on-your-github-profile-up-to-date</link><guid isPermaLink="true">https://davidmontesdeoca.dev/the-one-where-backups-keep-your-latest-posts-on-your-github-profile-up-to-date</guid><category><![CDATA[GitHub]]></category><category><![CDATA[GitHub Actions]]></category><category><![CDATA[markdown]]></category><category><![CDATA[Hashnode]]></category><category><![CDATA[automation tools]]></category><dc:creator><![CDATA[David Montesdeoca]]></dc:creator><pubDate>Sun, 26 Jul 2026 09:03:30 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/642d433eeaad3d174f737099/1feed15e-6ec4-4edf-9b50-c867f630befd.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A while ago I wrote <a href="/the-one-where-you-automatically-add-your-latest-posts-to-your-github-profile">a post</a> about automatically adding my latest blog posts to my GitHub profile. The idea was simple: a GitHub Action would query <a href="https://gql.hashnode.com/">Hashnode's public GraphQL API</a>, grab my most recent posts, and rewrite a section of the <code>README.md</code> with them. Whenever I published something new, a webhook chain would kick in and my profile would stay up to date without me lifting a finger.</p>
<p>It worked great. Until it did not.</p>
<h2>The free lunch ended</h2>
<p>If you visit <a href="https://gql.hashnode.com">https://gql.hashnode.com</a> today, you no longer land on the friendly GraphQL playground I used back then. Instead, you get redirected to <a href="https://hashnode.com/changelog/2026-05-13-graphql-api-paid-access">this changelog entry</a>.</p>
<p>The gist of it is this:</p>
<blockquote>
<p>Every API request, queries and mutations, now requires a Pro plan on your publication.</p>
</blockquote>
<p>So it is not just write operations behind the paywall. Even <strong>reading</strong> my own posts through the API now requires a paid subscription. Hashnode justifies the change as an abuse-prevention measure — apparently scrapers and spammers were mirroring content and flooding feeds — which is a perfectly reasonable motivation. But the practical consequence for me was that my nice little automation suddenly stopped working.</p>
<p>I had two options:</p>
<ul>
<li><p>Pay for Pro just to keep a <code>README.md</code> table updated (overkill, to put it mildly).</p>
</li>
<li><p>Find another source of truth for my posts.</p>
</li>
</ul>
<p>Thankfully, that source of truth was hiding in plain sight.</p>
<h2>The backups saved the day</h2>
<p>If you read the <a href="/the-one-where-you-automatically-add-your-latest-posts-to-your-github-profile#the-missing-piece">original post</a>, you might remember that Hashnode offers a <strong>GitHub integration</strong> that automatically backs up every post to a <a href="https://github.com/backpackerhh/blog-posts">repository of your choice</a>.</p>
<p>Back then I only used that repository as the <em>trigger</em> for my automation — a push to it fired a webhook that told my profile to go and re-query the API. The posts themselves still came from Hashnode.</p>
<p>Then I realized <strong>I do not need the API at all</strong>. Every post is already in that repository, as a Markdown file with all the metadata I need in its YAML frontmatter.</p>
<p>Here is what one of those backup files looks like at the top:</p>
<pre><code class="language-yaml">title: "The one about choosing where htmx logic belongs"
seoDescription: "Compare view-level events and server response headers to handle htmx failures. Explore the trade-offs and decide where your logic truly belongs."
datePublished: 2026-06-22T21:09:14.210Z
cuid: cmqppkjkw00000bj7cakd4owq
slug: the-one-about-choosing-where-htmx-logic-belongs
cover: https://cdn.hashnode.com/uploads/covers/642d433eeaad3d174f737099/5bc78409-7883-4e5b-9ec4-afc3251424e8.png
tags: http, javascript, html, security, hypermedia, htmx
</code></pre>
<p>Everything I was fetching over the network — <code>title</code>, <code>slug</code>, <code>datePublished</code>, <code>cover</code> — is right there. No API, no token, no Pro plan. Just files on disk.</p>
<h2>The new GitHub action</h2>
<p>So I created a replacement for the old action, <a href="https://github.com/backpackerhh/github-latest-blog-posts">backpackerhh/github-latest-blog-posts</a>, whose most important design decision is captured in the following:</p>
<blockquote>
<p>This action does not call any external HTTP API. The consumer workflow is responsible for checking out the blog-posts repository and passing its path to this action.</p>
</blockquote>
<p>This is a fundamental shift in responsibility. The old action <em>fetched</em> the data. The new one <em>reads</em> it. The workflow checks out the backup repository, and the action just parses the Markdown files it finds locally.</p>
<p>The metadata and configuration of the action live in <a href="https://github.com/backpackerhh/github-latest-blog-posts/blob/main/action.yml">action.yml</a>, and its <strong>inputs</strong> tell the whole story of how the philosophy changed:</p>
<table>
<thead>
<tr>
<th>Input</th>
<th>Required</th>
<th>Default</th>
</tr>
</thead>
<tbody><tr>
<td><code>POSTS_DIR</code></td>
<td>Yes</td>
<td>—</td>
</tr>
<tr>
<td><code>BLOG_BASE_URL</code></td>
<td>No</td>
<td><code>https://davidmontesdeoca.dev</code></td>
</tr>
<tr>
<td><code>README_FILE</code></td>
<td>No</td>
<td><code>./README.md</code></td>
</tr>
<tr>
<td><code>OPENING_COMMENT</code></td>
<td>No</td>
<td><code>&lt;!-- HASHNODE_POSTS:START --&gt;</code></td>
</tr>
<tr>
<td><code>CLOSING_COMMENT</code></td>
<td>No</td>
<td><code>&lt;!-- HASHNODE_POSTS:END --&gt;</code></td>
</tr>
<tr>
<td><code>MAX_POSTS</code></td>
<td>No</td>
<td><code>5</code></td>
</tr>
<tr>
<td><code>COMMIT_MESSAGE</code></td>
<td>No</td>
<td><code>chore(docs): update recent blog posts</code></td>
</tr>
</tbody></table>
<p>Notice what is <strong>gone</strong>: there is no <code>HASHNODE_PUBLICATION_ID</code> and no <code>GITHUB_TOKEN</code>. The only required input is now <code>POSTS_DIR</code> — the path to the local directory where the Markdown posts live.</p>
<p>A couple of things worth explaining:</p>
<ul>
<li><p><code>BLOG_BASE_URL</code> is used to reconstruct each post's public URL. Since the backups only store the <code>slug</code>, the action builds the link as <code>${BLOG_BASE_URL}/${slug}</code>. This means my profile links point to my own domain rather than to Hashnode, which I actually prefer.</p>
</li>
<li><p><code>OPENING_COMMENT</code> and <code>CLOSING_COMMENT</code> keep the same <code>HASHNODE_POSTS</code> markers I already had in my <code>README.md</code>, so I did not have to touch the placeholders at all. Backwards-compatible by happy accident.</p>
</li>
</ul>
<p>The <strong>runs</strong> section describes the execution environment. It uses <code>node24</code> as the runtime and <code>dist/index.js</code> as the entry point — same compiled-code approach as the old action, where the contents of the <code>dist</code> directory are what actually run.</p>
<p>As for how it actually reads the posts, the logic is refreshingly boring, and I mean that as a compliment.</p>
<p>The action:</p>
<ol>
<li><p>Scans <code>POSTS_DIR</code> for Markdown files.</p>
</li>
<li><p>Parses the YAML frontmatter of each file, expecting <code>title</code>, <code>seoDescription</code>, <code>datePublished</code>, <code>slug</code>, and <code>cover</code>.</p>
</li>
<li><p>Skips (with a warning) any file missing the required fields.</p>
</li>
<li><p>Sorts the posts by <code>datePublished</code>.</p>
</li>
<li><p>Takes the newest <code>MAX_POSTS</code> and formats them into a table between the comment markers in the <code>README.md</code>.</p>
</li>
</ol>
<p>If everything goes well, a commit is created with the changes — exactly the behavior I had before. From the outside, my profile updates the same way. Under the hood, not a single byte crosses the network to Hashnode.</p>
<h2>Wiring it together</h2>
<p>Here is the updated <a href="https://github.com/backpackerhh/backpackerhh/blob/main/.github/workflows/update-latest-blog-posts.yml">workflow</a> in my profile repository:</p>
<pre><code class="language-yaml">name: Update Latest Blog Posts

on:
  workflow_dispatch:
  # for trigger via webhooks
  repository_dispatch:
    types: [trigger]

jobs:
  update-posts:
    runs-on: ubuntu-latest
    name: Update Posts

    steps:
      - name: Checkout profile repo
        uses: actions/checkout@v7

      - name: Checkout blog-posts repo
        uses: actions/checkout@v7
        with:
          repository: backpackerhh/blog-posts
          path: .blog-posts
          ref: main

      - name: Update README with latest posts
        uses: backpackerhh/github-latest-blog-posts@main
        with:
          POSTS_DIR: .blog-posts
</code></pre>
<p>Once again, a fair amount is going on here, so let me explain it:</p>
<ul>
<li><p>The workflow can still be <a href="https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch">triggered manually</a> via <code>workflow_dispatch</code>.</p>
</li>
<li><p>It can still be <a href="https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#repository_dispatch">triggered by a webhook event</a>, listening for the <code>trigger</code> event type — the exact same trigger my dispatcher sends.</p>
</li>
<li><p>The first <a href="https://github.com/marketplace/actions/checkout">Checkout</a> step grabs my profile repository, as before.</p>
</li>
<li><p>The <strong>second Checkout step is the key change</strong>. It checks out <code>backpackerhh/blog-posts</code> into a <code>.blog-posts</code> directory. This is how the data gets onto the runner without any API call.</p>
</li>
<li><p>The final step runs the new action, pointing <code>POSTS_DIR</code> at that <code>.blog-posts</code> directory.</p>
</li>
</ul>
<p>The best part of this migration is that <a href="https://github.com/backpackerhh/blog-posts/blob/main/.github/workflows/dispatcher.yml">dispatcher.yml</a> in my blog-posts repository is untouched:</p>
<pre><code class="language-yaml">name: Dispatcher

on:
  push:
    branches: [main]

jobs:
  dispatch_event:
    name: Dispatch event
    runs-on: ubuntu-latest
    timeout-minutes: 2
    steps:
      - name: Dispatch
        run: |
            curl -L \
              -X POST \
              -H "Accept: application/vnd.github+json" \
              -H "Authorization: Bearer ${{ secrets.DISPATCH_TOKEN }}" \
              https://api.github.com/repos/backpackerhh/backpackerhh/dispatches \
              -d '{"event_type":"trigger"}'
</code></pre>
<p>The chain of events is identical to what I described in the <a href="/the-one-where-you-automatically-add-your-latest-posts-to-your-github-profile#the-missing-piece">previous post</a>:</p>
<ol>
<li><p>I publish, update, or delete a post on Hashnode.</p>
</li>
<li><p>Hashnode's GitHub integration pushes the change to <code>backpackerhh/blog-posts</code>.</p>
</li>
<li><p>That push to <code>main</code> triggers the dispatcher.</p>
</li>
<li><p>The dispatcher sends a <code>repository_dispatch</code> event with type <code>trigger</code> to my profile repository.</p>
</li>
<li><p>My profile's workflow wakes up, checks out the backups, and rewrites the <code>README.md</code>.</p>
</li>
</ol>
<p>The only link in the chain that changed is <strong>step 5</strong>, and even there, only the <em>source</em> of the data changed — from a remote API to a local checkout.</p>
<h2>Fewer moving parts, fewer things to break</h2>
<p>Looking back, this migration turned out to be a small blessing in disguise. Comparing the two setups side by side:</p>
<table>
<thead>
<tr>
<th></th>
<th>Old setup</th>
<th>New setup</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Data source</strong></td>
<td>Hashnode GraphQL API</td>
<td>Local Markdown backups</td>
</tr>
<tr>
<td><strong>Network call</strong></td>
<td>Yes (per run)</td>
<td>None</td>
</tr>
<tr>
<td><strong>Hashnode Pro required</strong></td>
<td>Yes (now)</td>
<td>No</td>
</tr>
<tr>
<td><strong>Secrets needed by the action</strong></td>
<td><code>HASHNODE_PUBLICATION_ID</code>, <code>GITHUB_TOKEN</code></td>
<td>None</td>
</tr>
<tr>
<td><strong>Post URLs point to</strong></td>
<td>Hashnode</td>
<td>My own domain</td>
</tr>
<tr>
<td><strong>Failure surface</strong></td>
<td>API downtime, rate limits, auth, schema changes</td>
<td>Frontmatter parsing</td>
</tr>
</tbody></table>
<p>The new approach is not only cheaper — it is <strong>more resilient</strong>. There is no API to go down, no rate limit to hit, no token to expire, and no GraphQL schema that might change under me. The backup repository is the single source of truth, and it is the same data Hashnode itself would have served me. If Hashnode ever changes the shape of that frontmatter, that is the only thing I would need to adapt.</p>
<h2>Conclusion</h2>
<p>When Hashnode put its API behind a Pro plan, my first reaction was mild annoyance at having something that worked perfectly well suddenly break. But the fix ended up leaving me with a setup that has fewer dependencies, no secrets in the critical path, and no external service to depend on at runtime.</p>
<p>The trigger mechanism I built back then survived intact — I just swapped the data source from a remote API to the local backups that were already being created for me. My original solution reached out over the network for data that was, in fact, already sitting on disk, and it took a paywall to make me notice the redundancy.</p>
<p>Sometimes the constraint that feels like a setback is the exact push you need toward the simpler design you should have had all along.</p>
<p>Thank you for reading and see you in the next one!</p>
]]></content:encoded></item><item><title><![CDATA[The one about choosing where htmx logic belongs]]></title><description><![CDATA[In the previous post I talked about how to override htmx's default behavior on failure. The proposed solution relied on events to handle any server-side error directly from the view.
Although I recomm]]></description><link>https://davidmontesdeoca.dev/the-one-about-choosing-where-htmx-logic-belongs</link><guid isPermaLink="true">https://davidmontesdeoca.dev/the-one-about-choosing-where-htmx-logic-belongs</guid><category><![CDATA[htmx]]></category><category><![CDATA[HTML]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Security]]></category><category><![CDATA[http]]></category><category><![CDATA[hypermedia]]></category><dc:creator><![CDATA[David Montesdeoca]]></dc:creator><pubDate>Mon, 22 Jun 2026 21:09:14 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/642d433eeaad3d174f737099/5bc78409-7883-4e5b-9ec4-afc3251424e8.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In the <a href="/the-one-about-overriding-htmx-s-default-behavior-on-failure">previous post</a> I talked about how to override htmx's default behavior on failure. The proposed solution relied on <a href="https://htmx.org/events/">events</a> to handle any server-side error directly from the view.</p>
<p>Although I recommend reading that post before moving on, here is the most relevant code from the <a href="/the-one-about-overriding-htmx-s-default-behavior-on-failure#a-new-approach">proposed approach</a>, which handles errors through htmx attributes defined directly on the form:</p>
<pre><code class="language-html">&lt;form
  id="new-comment-form"
  hx-post="/comments"
  hx-target="#comments-container"
  hx-swap="afterbegin"
  hx-select=".comment"
  hx-on::config-request="
    document.addEventListener('htmx:beforeSwap', function(e) {
      if (e.detail.isError) {
        e.detail.target = document.getElementById('new-comment-form');
        e.detail.swapOverride = 'outerHTML';
        e.detail.selectOverride = '#new-comment-form';
      }
    }, { once: true });
  "
&gt;
</code></pre>
<p>From now on, I will assume you know how that snippet works.</p>
<p>I have also been thinking about an alternative way to achieve the same behavior, using a different approach. Not necessarily better.</p>
<p>I will first present the alternative version of that code and then lay out the pros and cons of both options.</p>
<blockquote>
<p>The code shown below is a simplified version, removing unnecessary noise for this post, such as authorization, logging, HTML markup, or CSS styles.</p>
</blockquote>
<h2>A new alternative</h2>
<p>This approach keeps the form's attributes to a minimum and lets the server tell htmx what to do through <a href="https://htmx.org/reference/#response_headers">response headers</a> on failure.</p>
<h3>Endpoints</h3>
<pre><code class="language-ruby">post '/comments' do
  comment_params = {
    content: params[:content],
    author: current_user.email
  }
  result = CreateCommentCommand.call(comment_params)
  status_code, response = extract_response(result)

  case status_code
  when HTTP_CREATED_STATUS_CODE
    # ...
  else
    htmx_retarget("#new-comment-form")
    htmx_reselect("#new-comment-form")
    htmx_reswap("outerHTML")

    # ...
  end
end
</code></pre>
<pre><code class="language-ruby">module Request
  HTMX_RETARGET_HEADER_NAME = 'HX-Retarget'
  HTMX_RESELECT_HEADER_NAME = 'HX-Reselect'
  HTMX_RESWAP_HEADER_NAME = 'HX-Reswap'

  def htmx_retarget(target)
    response.set_header(HTMX_RETARGET_HEADER_NAME, target)
  end

  def htmx_reselect(target)
    response.set_header(HTMX_RESELECT_HEADER_NAME, target)
  end

  def htmx_reswap(swap)
    response.set_header(HTMX_RESWAP_HEADER_NAME, swap)
  end
end
</code></pre>
<p>Things to highlight in this code:</p>
<ul>
<li><p>The endpoint's behavior stays exactly the same, except for the call to the custom htmx helpers when any error is encountered while creating a comment.</p>
</li>
<li><p>Those helpers are defined in a module that is included in the application's base controller.</p>
</li>
<li><p>Those helpers set the corresponding <em>response header</em>, overriding the value defined in the matching htmx attribute on the requesting element — the form, in our case.</p>
</li>
</ul>
<h3>Presentation layer</h3>
<pre><code class="language-html">&lt;!-- comments/_new_comment_form.erb --&gt;
&lt;form
  id="new-comment-form"
  hx-post="&lt;%= comments_url %&gt;"
  hx-target="#comments-container"
  hx-swap="afterbegin"
  hx-select=".comment"
&gt;
  &lt;!-- ... --&gt;
&lt;/form&gt;
</code></pre>
<p>Things to highlight in this code:</p>
<ul>
<li>Only the attributes needed for the success case are defined in the form.</li>
</ul>
<h2>Comparing both approaches</h2>
<p>Same DOM result, same htmx contract, two very different places to put the logic. Let's compare them honestly.</p>
<h3>Approach A - View layer</h3>
<h4>Pros</h4>
<ul>
<li><p><strong>Co-location of behavior</strong>: Selector, swap strategy, and retarget element all live next to the form they affect. Somebody opening the view for the first time is able to see the full lifecycle without jumping into the controller. This is the <a href="https://htmx.org/essays/locality-of-behaviour/">Locality of Behavior (LoB)</a> principle that htmx itself advocates.</p>
</li>
<li><p><strong>Resilience to renames</strong>: The form ID lives in a single file. Rename it and the form keeps working — there is no controller silently pointing at an old selector.</p>
</li>
<li><p><strong>Controller stays HTTP-pure</strong>: The server returns <code>4xx</code> and an HTML fragment. It knows nothing about DOM IDs or htmx swap mechanics.</p>
</li>
<li><p><strong>Reusable across endpoints</strong>: If multiple endpoints could fail and target the same form, the form itself owns the retarget logic. None of those endpoints have to remember to set the same headers to make it work.</p>
</li>
</ul>
<h4>Cons</h4>
<ul>
<li><p><strong>Inline JavaScript inside a string</strong>: If you want to stick to the LoB principle, you have to write the JS code inside an HTML attribute that is just a string. Your IDE treats it as HTML — no syntax highlighting, no autocomplete, no jump-to-definition — and debugging is harder.</p>
</li>
<li><p><strong>Duplication</strong>: Every form needing this behavior copies essentially the same <code>hx-on::config-request</code> block, with only the target ID changing. The more forms you have, the more duplication. A Ruby helper could render that block but then it would just hide the JS code inside a string in a different file, breaking the LoB principle.</p>
</li>
<li><p><strong>Status-code blindness</strong>: The view cannot easily differentiate <code>422</code> (validation error) from <code>409</code> (conflict) from <code>500</code> (server error) without growing the inline script.</p>
</li>
<li><p><strong>CSP friction</strong>: If the app does not enforce a strict <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP">Content Security Policy</a>, this is a non-issue. But if it does — or might in the future — <code>hx-on::*</code> attributes force you into either <code>'unsafe-inline'</code> (which weakens XSS protection across the entire site) or per-attribute <code>'unsafe-hashes'</code> (which you have to recompute every time the attribute changes).</p>
</li>
</ul>
<h3>Approach B - Controller layer</h3>
<h4>Pros</h4>
<ul>
<li><p><strong>Documented contract</strong>: <code>HX-Retarget</code>, <code>HX-Reswap</code>, and <code>HX-Reselect</code> are first-class htmx response headers — stable across versions, no clever workaround, no mutation of internal event details.</p>
</li>
<li><p><strong>Status-code routing</strong>: The controller already knows whether it returned <code>422</code> (validation error), <code>409</code> (conflict), or <code>500</code> (server error), and can trivially return different swap behaviors for each scenario.</p>
</li>
<li><p><strong>No inline JS</strong>: Pure HTTP response. Passes strict CSP without exceptions.</p>
</li>
<li><p><strong>Debuggability</strong>: Easier to debug with a simple <code>curl -i</code> or a glance at the network tab, revealing exactly what the server instructed the client to do.</p>
</li>
</ul>
<h4>Cons</h4>
<ul>
<li><p><strong>Leaks in the controller</strong>: DOM IDs (<code>"#new-comment-form"</code>) and swap strategy (<code>"outerHTML"</code>) are presentation concerns leaking into the HTTP layer.</p>
</li>
<li><p><strong>Silent breakage on rename</strong>: Rename the form ID and the controller would break silently.</p>
</li>
<li><p><strong>Drift risk</strong>: Every endpoint that could fail and target the same form must set the same headers.</p>
</li>
<li><p><strong>No co-location of behavior</strong>: Somebody opening the view for the first time is not able to easily see how errors are handled.</p>
</li>
</ul>
<h2>The tie-breakers</h2>
<p>Both approaches work. Both use documented htmx machinery. Here are the factors that I think should help you decide.</p>
<h3>Where does this knowledge belong?</h3>
<p>The knowledge being expressed is:</p>
<blockquote>
<p>When this form fails to submit, replace itself with the server's re-rendered version showing errors.</p>
</blockquote>
<ul>
<li><p>The server's job is to validate the input, return <code>422</code>, and render the form.</p>
</li>
<li><p>The form's job is to replace itself with the server's response if the submission fails.</p>
</li>
<li><p>The thing being expressed is therefore form behavior, not server behavior.</p>
</li>
</ul>
<p>By that lens, the view layer wins. The controller should not care that the response is going into a form, a modal, or a toast. It returns a <code>422</code> and how the UI displays that is the UI's problem.</p>
<h3>Content Security Policy</h3>
<p>If your app already enforces a strict CSP, the question is decided for you — <code>hx-on::*</code> attributes are off the table without weakening XSS protection across the whole site. If your app might enforce one in the future, you are choosing between paying the migration cost later or staying on the controller side now.</p>
<p>By that lens, the controller approach wins — but only if CSP is on your roadmap.</p>
<h3>Codebase consistency</h3>
<p>In an existing codebase with established patterns, a slightly misaligned but consistently applied convention is more valuable than two competing patterns.</p>
<p>For instance, if your codebase already uses <code>htmx_retarget</code> or <code>htmx_reswap</code> somewhere, introducing <code>hx-on::config-request</code> for one new form forces every future reader to ask "why is this one different?", with no good answer. The same applies in reverse — if every existing form uses <code>hx-on::*</code>, dropping headers into the controller for this one form has the same problem.</p>
<p>By that lens, the existing convention should usually win. The cost of having two patterns to maintain is almost always higher than the cost of putting one of them in a slightly suboptimal layer.</p>
<h2>Conclusion</h2>
<p>Both approaches use documented htmx machinery, both produce the same DOM result, and both are cheap to refactor away from. The choice is not really about which one is correct — it is about which one fits the codebase you already have.</p>
<p>In my case, I ultimately chose the controller layer because we use <code>htmx_*</code> helpers in other parts of the application. The "codebase consistency" tie-breaker outweighed the "knowledge belongs in the view" argument — and I think that is the right call more often than not, even when it feels architecturally less pure.</p>
<p>Pick the boundary that fits your team and codebase, write it down, and move on.</p>
<p>Thank you for reading and see you in the next one!</p>
]]></content:encoded></item><item><title><![CDATA[The one about overriding htmx's default behavior on failure]]></title><description><![CDATA[The project that I have been working on for the past few months is coming to an end. A few days ago, we even presented a demo of the new UI to the stakeholders, who have already started using it in pr]]></description><link>https://davidmontesdeoca.dev/the-one-about-overriding-htmx-s-default-behavior-on-failure</link><guid isPermaLink="true">https://davidmontesdeoca.dev/the-one-about-overriding-htmx-s-default-behavior-on-failure</guid><category><![CDATA[htmx]]></category><category><![CDATA[CSS]]></category><category><![CDATA[Tailwind CSS]]></category><category><![CDATA[Ruby]]></category><category><![CDATA[sinatrarb]]></category><category><![CDATA[HTML]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Ajax]]></category><category><![CDATA[hypermedia]]></category><category><![CDATA[claude-code]]></category><category><![CDATA[AI]]></category><category><![CDATA[ui components]]></category><dc:creator><![CDATA[David Montesdeoca]]></dc:creator><pubDate>Sun, 31 May 2026 10:16:54 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/642d433eeaad3d174f737099/3ee4fa5a-0a78-412d-ad82-d1680987eeee.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The <a href="/the-one-about-a-new-project-working-for-an-american-fintech">project</a> that I have been working on for the past few months is coming to an end. A few days ago, we even presented a demo of the new UI to the stakeholders, who have already started using it in production. Although the feedback has been very positive, they are used to working in a certain way with the old UI and requested some changes so that the transition to the new UI causes the least amount of friction possible.</p>
<p>One of those changes is related to adding new comments. Right now, the user has to click a button to add the form to the page using <a href="https://htmx.org/">htmx</a>. The stakeholders prefer to have the comment box always visible to streamline their workflow by requiring fewer clicks.</p>
<p>At the design level, we need to eliminate the button that adds the new comment form to the page and the "Cancel" button that removes said form from the page. Since it is always visible, it is no longer necessary.</p>
<p>At the functional level, we need to remove the endpoint that returns the HTML for the new comment form and change some details in the htmx configuration related to that logic.</p>
<blockquote>
<p>The code shown below is a simplified version, removing unnecessary noise for this post, such as authorization, logging, HTML markup, or CSS styles.</p>
</blockquote>
<h2>Initial state</h2>
<h3>Endpoints</h3>
<p>This is a <a href="https://sinatrarb.com/">Sinatra</a> application, where we have the following endpoints:</p>
<pre><code class="language-ruby">get '/comments/new' do
  render_partial 'comments/_new_comment_form', locals: NewCommentPresenter.present
end

post '/comments' do
  comment_params = {
    content: params[:content],
    author: current_user.email
  }
  result = CreateCommentCommand.call(comment_params)
  status_code, response = extract_response(result)

  case status_code
  when HTTP_CREATED_STATUS_CODE
    render_partial 'comments/_comment',
                    locals: CommentPresenter.present(response),
                    flash: { type: :success, message: 'Comment added successfully' }
  else
    render_partial 'comments/_new_comment_form',
                    locals: NewCommentPresenter.present(comment_params, errors: response),
                    flash: { type: :error, message: 'Unable to add comment. Please try again or contact support.' }
  end
end
</code></pre>
<p>Things to highlight in this code:</p>
<ul>
<li><p>The command returns an instance of the <a href="https://hanakai.org/learn/dry/dry-monads/result">Result</a> monad, <code>Success</code> or <code>Failure</code>, provided by <a href="https://hanakai.org/learn/dry/dry-monads/">dry-monads</a>.</p>
</li>
<li><p><code>extract_response</code> checks the result from the command and extracts the response using <a href="https://docs.ruby-lang.org/en/4.0/syntax/pattern_matching_rdoc.html">pattern matching</a>.</p>
</li>
<li><p>Depending on the <code>status_code</code>, either the newly created comment or the same form with the errors found during server validation is rendered.</p>
</li>
<li><p>Presenters only receive data and are responsible for shaping it before passing it to the partials or views.</p>
</li>
<li><p>The flash alert displays a message with feedback for the user, appearing at the top right of the page and disappearing automatically after x seconds. I would like to talk about this implementation in a later post.</p>
</li>
<li><p>The server response is implicitly <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/200">200 OK</a> in both cases, so the response is always inserted into the DOM.</p>
</li>
</ul>
<h3>Presentation layer</h3>
<p>These are templates in ERB format, both views and partials:</p>
<pre><code class="language-html">&lt;!-- comments/index.erb --&gt;
&lt;h2&gt;Comments&lt;/h2&gt;

&lt;%=
  link_button(
    'Add new comment',
    htmx: {
      get: '/comments/new',
      target: '#comments-container',
      swap: 'afterbegin show:#new-comment-form:top swap:50ms',
      'on::before-request': "if(document.querySelector('#new-comment-form')) { event.preventDefault() }"
    }
  )
%&gt;

&lt;div id="comments-container"&gt;
  &lt;% if comments.empty? %&gt;
    &lt;p id="no-comments"&gt;No comments yet&lt;/p&gt;
  &lt;% else %&gt;
    &lt;% comments.each do |comment| %&gt;
      &lt;%= render_partial 'comments/_comment', locals: { comment: } %&gt;
    &lt;% end %&gt;
  &lt;% end %&gt;
&lt;/div&gt;
</code></pre>
<pre><code class="language-html">&lt;!-- comments/_new_comment_form.erb --&gt;
&lt;form
  id="new-comment-form"
  hx-post="/comments"
  hx-target="#comments-container"
  hx-swap="afterbegin"
  hx-select=".comment, #new-comment-form"
  hx-on::after-request="if(event.detail.successful) { document.getElementById('no-comments')?.remove(); this.remove(); }"
&gt;
  &lt;%=
    textarea(
      name: 'content',
      label: 'New comment',
      value: comment[:content],
      error: errors[:content],
      required: true
    )
  %&gt;

  &lt;%= secondary_button('Cancel', onclick: "this.closest('form').remove();") %&gt;
  &lt;%= primary_button('Add', type: :submit) %&gt;
&lt;/form&gt;
</code></pre>
<p>Things to highlight in this code:</p>
<ul>
<li><p><a href="/the-one-with-the-ruby-dsl-for-ui-components">Server-rendered UI components</a> are used for buttons and form fields.</p>
</li>
<li><p>The new comment form is added above the list of existing comments, provided it is not already present on the page, and the view automatically scrolls to the top of the form.</p>
</li>
<li><p>The new comment or the new comment form with errors is extracted from the server response:</p>
<ul>
<li><p>In case the comment is successfully created, the new comment is added to the top of the comment list, the new comment form is removed, and the "No comments yet" message is removed.</p>
</li>
<li><p>In case of finding any failure when creating the comment, the same form is rendered again with the errors found.</p>
</li>
</ul>
</li>
<li><p>Clicking on the "Cancel" button removes the form from the page.</p>
</li>
</ul>
<h2>Implementation of the required changes</h2>
<h3>Endpoints</h3>
<p>As I mentioned previously, the <code>/comments/new</code> endpoint is no longer necessary. Nothing changes in the other endpoint.</p>
<h3>Presentation layer</h3>
<pre><code class="language-html">&lt;!-- comments/index.erb --&gt;
&lt;h2&gt;Comments&lt;/h2&gt;

&lt;%= render_partial 'comments/_new_comment_form', locals: { comment: {} } %&gt;

&lt;div id="comments-container"&gt;
  &lt;% if comments.empty? %&gt;
    &lt;p id="no-comments"&gt;No comments yet&lt;/p&gt;
  &lt;% else %&gt;
    &lt;% comments.each do |comment| %&gt;
      &lt;%= render_partial 'comments/_comment', locals: { comment: } %&gt;
    &lt;% end %&gt;
  &lt;% end %&gt;
&lt;/div&gt;
</code></pre>
<pre><code class="language-html">&lt;!-- comments/_new_comment_form.erb --&gt;
&lt;form
  id="new-comment-form"
  hx-post="/comments"
  hx-target="#comments-container"
  hx-swap="afterbegin"
  hx-select=".comment, #new-comment-form"
&gt;
  &lt;%=
    textarea(
      name: 'content',
      label: 'New comment',
      value: comment[:content],
      error: errors[:content],
      required: true
    )
  %&gt;

  &lt;%= primary_button('Add', type: :submit) %&gt;
&lt;/form&gt;
</code></pre>
<p>Things to highlight in this code:</p>
<ul>
<li><p>The "Add new comment" button is replaced by the new comment form.</p>
</li>
<li><p>It is not necessary to check if the form is already present on the page using JS.</p>
</li>
<li><p>The "Cancel" button is no longer necessary.</p>
</li>
<li><p>In case the request to the server is successful, the form itself is not removed, nor is the "No comments yet" message.</p>
</li>
</ul>
<p>The good news is that while removing the <code>hx-on::after-request</code> attribute, I discovered a bug in the code, which occurred only in case of finding an error on the server:</p>
<pre><code class="language-plaintext">hx-on::after-request="if(event.detail.successful) { document.getElementById('no-comments')?.remove(); this.remove(); }"
</code></pre>
<blockquote>
<p><a href="https://htmx.org/attributes/hx-on/">hx-on attributes docs</a></p>
</blockquote>
<p>The key lies in the status code returned by the server, which we already mentioned is 200 OK by default, so after each request, it removed the original form and the "No comments yet" message.</p>
<p>The new change causes that, when submitting the form, if any validation error is found on the server, the form with the errors is rendered below the original form, which is no longer removed. The more requests that are made, the more forms will appear on the page.</p>
<p>Of course, the "No comments yet" message is never removed either, even when it is necessary to do so.</p>
<h2>A new approach</h2>
<p>The goal is to <strong>have a different configuration for successful and failed requests</strong>.</p>
<p>We start by returning the correct status code from the server in any case that is not a success:</p>
<pre><code class="language-ruby">post '/comments' do
  # ...
  status_code, response = extract_response(result)

  case status_code
  when HTTP_CREATED_STATUS_CODE
    render_partial 'comments/_comment',
                    locals: CommentPresenter.present(response),
                    flash: { type: :success, message: 'Comment added successfully' }
  else
    status status_code
    render_partial 'comments/_new_comment_form',
                    locals: NewCommentPresenter.present(comment_params, errors: response),
                    flash: { type: :error, message: 'Unable to add comment. Please try again or contact support.' }
  end
end
</code></pre>
<p>On the other hand, we must first configure the <a href="https://htmx.org/docs/#requests">htmx responses</a>, allowing the content returned by the server for <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status#client_error_responses">client error responses</a> to be replaced on the page:</p>
<pre><code class="language-js">document.addEventListener("DOMContentLoaded", function() {
  htmx.config.responseHandling = [
    // ...
    { code: "4..", swap: true, error: true }
    // ...
  ]
});
</code></pre>
<p>The htmx documentation recommends using the <a href="https://htmx.org/extensions/response-targets/">Response Targets extension</a>, which allows you to configure the behavior of response codes declaratively via attributes.</p>
<p>However, that extension falls short for my use case, as it only allows specifying different target elements. I would have needed something like the following:</p>
<pre><code class="language-html">&lt;form
  id="new-comment-form"
  hx-post="/comments"
  hx-target="#comments-container"
  hx-swap="afterbegin"
  hx-select=".comment"
  hx-target-error="this"
  hx-swap-error="outerHTML"
  hx-select-error="#new-comment-form"
&gt;
</code></pre>
<blockquote>
<p>Perhaps at some point I will have enough time to create a <a href="https://htmx.org/extensions/">custom extension</a> for this use case.</p>
</blockquote>
<p>Another alternative offered by htmx is to modify the <a href="https://htmx.org/docs/#modifying_swapping_behavior_with_events">swapping behavior of the page content with events</a>:</p>
<pre><code class="language-html">&lt;form
  id="new-comment-form"
  hx-post="/comments"
  hx-target="#comments-container"
  hx-swap="afterbegin"
  hx-select=".comment"
  hx-on::before-swap="
    if (event.detail.isError) {
      event.detail.target = document.getElementById('new-comment-form');
      event.detail.swapOverride = 'outerHTML';
      event.detail.selectOverride = '#new-comment-form';
    }
  "
&gt;
</code></pre>
<p>In my opinion, it is less elegant than what I expected from the extension, but I find it to be a sufficiently good alternative.</p>
<p>To my surprise, defining the <a href="https://htmx.org/events/#htmx:beforeSwap">htmx:beforeSwap event</a> that way does not work, because it does not execute on the requesting element, but on the target element.</p>
<p>It is possible to declare the event on the target element or on a common ancestor to both, but I quickly discarded that option. Imagine coming across the following code without any context:</p>
<pre><code class="language-html">&lt;div
  hx-on::before-swap="
    if (event.detail.isError) {
      event.detail.target = document.getElementById('new-comment-form');
      event.detail.swapOverride = 'outerHTML';
      event.detail.selectOverride = '#new-comment-form';
    }
  "
&gt;
  &lt;!-- the requesting element could be one of its children --&gt;
&lt;/div&gt;
</code></pre>
<p>Would you not wonder what on earth that is doing there?</p>
<p>It can make some sense if the target element or the common ancestor are defined in the same view, but if they are defined in different views, as is this case, I prefer to follow the <a href="https://htmx.org/essays/locality-of-behaviour/">Locality of Behavior (LoB)</a> principle.</p>
<p>Here, the help of Claude Code was decisive, since it suggested using <a href="https://htmx.org/events/#htmx:configRequest">htmx:configRequest event</a> instead:</p>
<pre><code class="language-html">&lt;form
  id="new-comment-form"
  hx-post="/comments"
  hx-target="#comments-container"
  hx-swap="afterbegin"
  hx-select=".comment"
  hx-on::config-request="
    document.addEventListener('htmx:beforeSwap', function onBeforeSwap(e) {
      document.removeEventListener('htmx:beforeSwap', onBeforeSwap);
      if (e.detail.isError) {
        e.detail.target = document.getElementById('new-comment-form');
        e.detail.swapOverride = 'outerHTML';
        e.detail.selectOverride = '#new-comment-form';
      }
    });
  "
&gt;
</code></pre>
<p>By using <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener">addEventListener</a>, we subscribe to the event we want and define the desired target.</p>
<p>Knowing the tendency of AI to include more code than necessary, I questioned it on whether the use of <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener">removeEventListener</a> was truly necessary in this case.</p>
<p>Its arguments definitely convinced me:</p>
<ul>
<li><p>If the listener is not removed, they would accumulate over time, since <code>htmx:configRequest</code> event is fired every time the form is submitted.</p>
</li>
<li><p>Each obsolete listener continues to execute and attempts to redirect the swap. In this case, all of them would perform the same task, so from a functional point of view, it might appear to work, but it could be a memory or event leak that grows without limits.</p>
</li>
</ul>
<p>What I always do when I use AI to write code is to ask if it can do it in a simpler and/or more elegant way. On this occasion, it suggested the possibility of passing the <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#once">once argument to addEventListener</a> to automatically remove the listener after being invoked:</p>
<pre><code class="language-html">&lt;form
  id="new-comment-form"
  hx-post="/comments"
  hx-target="#comments-container"
  hx-swap="afterbegin"
  hx-select=".comment"
  hx-on::config-request="
    document.addEventListener('htmx:beforeSwap', function(e) {
      if (e.detail.isError) {
        e.detail.target = document.getElementById('new-comment-form');
        e.detail.swapOverride = 'outerHTML';
        e.detail.selectOverride = '#new-comment-form';
      }
    }, { once: true });
  "
&gt;
</code></pre>
<p>Having reached this point, two main issues remain in the code:</p>
<ul>
<li><p>In case of submitting the new comment form and finding validation errors on the server, the textarea renders with a red border and the corresponding error message below it. If the user subsequently fills out the form correctly and submits it, the comment is created successfully and added to the comment list, but visually the form remains as it was. Due to the applied styles and validation errors, simply performing a <a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/reset">reset</a> of the form does not work, because it basically only clears the content of the comment.</p>
</li>
<li><p>The "No comments yet" message still is not removed when the first comment is added to the page.</p>
</li>
</ul>
<p>Next, I show how to solve both problems in a simple way.</p>
<p>In the controller, in case of successfully creating a comment, a new partial specific to this use case is rendered:</p>
<pre><code class="language-ruby">post '/comments' do
  # ...
  status_code, response = extract_response(result)

  case status_code
  when HTTP_CREATED_STATUS_CODE
    render_partial 'comments/_comment_created',
                    locals: CommentPresenter.present(response),
                    flash: { type: :success, message: 'Comment added successfully' }
  else
    # ...
  end
end
</code></pre>
<p>In that new partial, the newly created comment is rendered, and the form is replaced with a new one by making use of the <a href="https://htmx.org/attributes/hx-swap-oob/">hx-swap-oob</a> attribute, which allows you to specify that some content in a response should be swapped into the DOM somewhere other than the target:</p>
<pre><code class="language-html">&lt;!-- comments/_comment_created.erb --&gt;
&lt;%= render_partial 'comments/_comment', locals: { comment: } %&gt;

&lt;div hx-swap-oob="outerHTML:#new-comment-form"&gt;
  &lt;%= render_partial 'comments/_new_comment_form', locals: { comment: {} } %&gt;
&lt;/div&gt;
</code></pre>
<p>And in the comment list we can apply a <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:has">CSS trick</a>, in this case making use of <a href="https://tailwindcss.com/docs/hover-focus-and-other-states#has">TailwindCSS</a>, so that it only shows the well-known "No comments yet" message when appropriate:</p>
<pre><code class="language-html">&lt;!-- comments/index.erb --&gt;
&lt;div id="comments-container" class="group/no-comments"&gt;
  &lt;p class="group-has-[.comment]/no-comments:hidden"&gt;No comments yet&lt;/p&gt;

  &lt;% comments.each do |comment| %&gt;
    &lt;%= render_partial 'comments/_comment', locals: { comment: } %&gt;
  &lt;% end %&gt;
&lt;/div&gt;
</code></pre>
<p>Now, everything works exactly as I expected. Hooray!</p>
<h2>Conclusion</h2>
<p>What started as a simple UX request – keeping a comment form always visible – turned into a great exercise in understanding the htmx event lifecycle and DOM manipulation.</p>
<p>It is easy to love a tool like htmx when you are cruising down the happy path. The real engineering happens when you hit a failure path and realize the library's default behavior does not match your product requirements, and have to decide how to bend the tool to your will.</p>
<p>Overriding htmx's default behavior on failure forced me to think about where the UI logic truly belongs. By keeping that logic inside the view layer, <strong>Locality of Behavior</strong> was preserved and kept the backend clean. It proves that with a solid understanding of how a framework processes requests, you do not need a heavy JavaScript framework to build complex, resilient user interfaces.</p>
<p>Ultimately, the stakeholders got a lower-friction UI that matches their workflow, and I got a much deeper understanding of htmx's defaults.</p>
<p>Thank you for reading and see you in the next one!</p>
]]></content:encoded></item><item><title><![CDATA[The one with the Ruby DSL for UI components]]></title><description><![CDATA[In a previous post I talked about the internal design system I have been working on recently, consisting of server-rendered UI components, using Ruby and ERB templates.
Most of these components are si]]></description><link>https://davidmontesdeoca.dev/the-one-with-the-ruby-dsl-for-ui-components</link><guid isPermaLink="true">https://davidmontesdeoca.dev/the-one-with-the-ruby-dsl-for-ui-components</guid><category><![CDATA[Ruby]]></category><category><![CDATA[Rails]]></category><category><![CDATA[Ruby on Rails]]></category><category><![CDATA[UI]]></category><category><![CDATA[ui components]]></category><category><![CDATA[ERB]]></category><category><![CDATA[dsl]]></category><category><![CDATA[HTML]]></category><category><![CDATA[CSS]]></category><category><![CDATA[sinatrarb]]></category><dc:creator><![CDATA[David Montesdeoca]]></dc:creator><pubDate>Tue, 28 Apr 2026 19:00:58 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/642d433eeaad3d174f737099/34ba6dbd-6d95-40d7-b5ed-c14af0edf898.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In a <a href="/the-one-about-rendering-and-displaying-code-examples-in-erb">previous post</a> I talked about the internal design system I have been working on recently, consisting of server-rendered UI components, using Ruby and <a href="https://ruby-doc.org/stdlib/libdoc/erb/rdoc/ERB.html">ERB</a> templates.</p>
<p>Most of these components are simple: you call a method and the page renders the HTML returned by the corresponding template.</p>
<p>Some examples of components:</p>
<pre><code class="language-ruby">primary_button "Save"
status_badge "Finished", color: "green"
text_input name: "name", label: "Name", value: "David"
dropdown name: "country", label: "Country", options: countries
</code></pre>
<p>Of course, not all components are that simple.</p>
<h2>The problem</h2>
<p>The first complex component I had to create was a table. I mentioned in the previous post that my first approach was to create a CSS-only component:</p>
<pre><code class="language-html">&lt;table class="table"&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Name&lt;/th&gt;
      &lt;th&gt;Email&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Alice&lt;/td&gt;
      &lt;td&gt;alice@example.com&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
</code></pre>
<p>I soon realized that a component of that kind is not really useful. While it is true that defining styles is very simple this way, adding common behavior for the different applications that use it, such as making the content of the different columns sortable, is not.</p>
<h2>The first block DSL component: table</h2>
<p>The goal was to create a <code>table</code> component that would allow consumers to write something like the following inside a single <code>&lt;%= %&gt;</code> tag:</p>
<pre><code class="language-ruby">table do
  table_head do
    table_row do
      table_header_cell("Name", **sortable_column_params(:name, sorting_params:))
      table_header_cell("Email", **sortable_column_params(:email, sorting_params:))
    end
  end
  table_body do
    table_row do
      table_cell("Alice")
      table_cell("alice@example.com")
    end
  end
end
</code></pre>
<p>Where <code>**sortable_column_params</code> would return a hash containing the required configuration for that column to be properly sorted:</p>
<pre><code class="language-ruby">{ sortable: true, sort_direction: nil | "asc" | "desc", htmx: { ... } }
</code></pre>
<p>This is harder than it looks. In a plain Ruby method, each helper runs, returns a string, and the outer block has no visibility into what its children did.</p>
<p>The calls to each one of those methods return strings independently. There is no shared place for them to store their rendered HTML.</p>
<p>In situations like this, I usually look at the Rails source code to see how the framework handles it and whether there is a simple way to replicate that approach, instead of adding more dependencies to the project.</p>
<blockquote>
<p>In a Rails view, every <code>&lt;%= ... %&gt;</code> tag appends its result to <code>@output_buffer</code>, an <code>ActionView::OutputBuffer</code> maintained during rendering.</p>
<p><code>capture { ... }</code> works by swapping the buffer: it saves the current <code>@output_buffer</code>, installs a fresh one, yields the block (so any <code>&lt;%= ... %&gt;</code> inside writes into the new buffer), then restores the original and returns what was captured.</p>
<p>Block-form helpers like <code>content_tag(:div) { ... }</code> use capture internally to collect their block's output before wrapping it in markup.</p>
<p>But this only works when the caller is an ERB template rendered by <code>ActionView</code>. In plain Sinatra there is no <code>@output_buffer</code> and no <code>capture</code>, so the nested DSL above has nowhere to collect its children's output.</p>
</blockquote>
<p>Next, I shared what I wanted to achieve with Claude Code in <em>plan mode</em>, and after a few iterations, I got the AI to propose an implementation that seemed simple enough.</p>
<p>The implementation, slightly simplified, is as follows:</p>
<pre><code class="language-ruby">module Components
  module Table
    def table(&amp;block)
      content = capture_table_content(&amp;block)

      Components.load_template("table/_table.erb", binding)
    end

    def table_head(&amp;block)
      content = capture_table_content(&amp;block)
      result = Components.load_template("table/_head.erb", binding)

      append_to_table_buffer(result)
    end

    def table_body(&amp;block)
      content = capture_table_content(&amp;block)
      result = Components.load_template("table/_body.erb", binding)

      append_to_table_buffer(result)
    end

    def table_row(&amp;block)
      content = capture_table_content(&amp;block)
      result = Components.load_template("table/_row.erb", binding)

      append_to_table_buffer(result)
    end

    def table_header_cell(content, **sortable_params)
      result = Components.load_template("table/_header_cell.erb", binding)

      append_to_table_buffer(result)
    end

    def table_cell(content)
      result = Components.load_template("table/_cell.erb", binding)

      append_to_table_buffer(result)
    end

    # other helpers omitted

    private

    def table_buffer_stack
      Thread.current[:table_buffer_stack] ||= []
    end

    def capture_table_content(&amp;block)
      table_buffer_stack.push([])
      block.call
      table_buffer_stack.pop.join
    end

    def append_to_table_buffer(html)
      table_buffer_stack.last &lt;&lt; html if table_buffer_stack.any?

      html
    end
  end
end
</code></pre>
<p>Two ideas are doing the work here:</p>
<ul>
<li><p><strong>A stack of arrays</strong> instead of a single buffer, so nested blocks do not stomp on each other:</p>
<pre><code class="language-ruby">table do              # buffer_stack = [[]]
  table_body do       # buffer_stack = [[], []]
    table_row do      # buffer_stack = [[], [], []]
      table_cell "a"  # buffer_stack = [[], [], ["&lt;td&gt;a&lt;/td&gt;"]]
      table_cell "b"  # buffer_stack = [[], [], ["&lt;td&gt;a&lt;/td&gt;", "&lt;td&gt;b&lt;/td&gt;"]]
    end               # buffer_stack = [[], ["&lt;tr&gt;&lt;td&gt;a&lt;/td&gt;&lt;td&gt;b&lt;/td&gt;&lt;/tr&gt;"]]
  end                 # buffer_stack = [["&lt;tbody&gt;…&lt;/tbody&gt;"]]
end                   # returns "&lt;table&gt;&lt;tbody&gt;…&lt;/tbody&gt;&lt;/table&gt;"
</code></pre>
<p><code>table_body</code> pushes a fresh array; its inner <code>table_row</code> pushes another on top; when each <code>pop</code>s, the content of the inner stack merges back into the outer one via <code>append_to_table_buffer</code>. Only the outermost <code>table</code> returns anything, the joined string after the last pop. Every other call's return value is thrown away on purpose: if <code>capture_table_content</code> trusted the block's return value, Ruby's last-expression rule would hand back only the last <code>table_cell</code>'s HTML and every earlier cell would vanish.</p>
<p>Nesting a table <em>inside</em> another table works for the same reason, an inner <code>table do</code> just pushes one more array onto the stack, fills it, pops it, and deposits the whole inner <code>&lt;table&gt;…&lt;/table&gt;</code> string onto its parent's array like any other child's output.</p>
</li>
<li><p><strong>Thread-local storage</strong>, because Sinatra on a threaded server, like <a href="https://github.com/puma/puma">Puma</a>, handles requests in parallel:</p>
<pre><code class="language-ruby">Thread.current[:table_buffer_stack]
</code></pre>
<p>If <code>table_buffer_stack</code> were a class-level <code>@@buffer_stack</code> instead, two requests entering <code>table do</code> at the same moment would push onto the same array; each one's <code>table_cell</code> could append into the other's array, mixing rows across responses. <code>Thread.current</code> gives each thread its own stack, so the two requests can not see each other's state.</p>
</li>
</ul>
<p>All that works perfectly for the table component, but it does not for the next component I needed to create.</p>
<h2>The second block DSL component: modal</h2>
<p>The goal was to create a <code>modal</code> component that would allow consumers to write something like the following inside a single <code>&lt;%= %&gt;</code> tag:</p>
<pre><code class="language-ruby">modal do
  modal_header do
    "Confirm status change"
  end
  modal_body do
    &lt;&lt;~CONTENT
      &lt;p&gt;Do you confirm the change of status?&lt;/p&gt;
      &lt;p&gt;&lt;span&gt;OLD_STATUS&lt;/span&gt; -&gt; &lt;span&gt;NEW_STATUS&lt;/span&gt;&lt;/p&gt;
    CONTENT
  end
  modal_footer do
    modal_cancel
    modal_action "Confirm", htmx: { ... }
  end
end
</code></pre>
<p>The implementation is basically the same as for the <code>table</code> component:</p>
<pre><code class="language-ruby">module Components
  module Modal
    def modal(&amp;block)
      content = capture_modal_content(&amp;block)

      Components.load_template("modal/_modal.erb", binding)
    end

    # other helpers omitted

    private

    def modal_buffer_stack
      Thread.current[:modal_buffer_stack] ||= []
    end

    def capture_modal_content(&amp;block)
      modal_buffer_stack.push([])
      result = block.call
      buffer = modal_buffer_stack.pop

      buffer.empty? ? result.to_s : buffer.join
    end
  end
end
</code></pre>
<p>There are only two differences:</p>
<ul>
<li><p>The thread-local key used to store the component's content in <code>Thread.current</code> is different, <code>:modal_buffer_stack</code>.</p>
</li>
<li><p>There is an important change in the method that captures the component's content:</p>
<pre><code class="language-ruby">buffer.empty? ? result.to_s : buffer.join
</code></pre>
<p>It falls back to the block's return value if the block did not call any DSL child.</p>
<p>For instance:</p>
<pre><code class="language-ruby">modal_header { "Confirm status change" }
</code></pre>
<p>The block returns a bare string, so nothing is pushed to the buffer. Without the fallback, those blocks would render empty.</p>
<p>Table got away with <code>pop.join</code> because every <code>table</code> block is expected to contain child DSL calls (<code>table_row</code>, <code>table_cell</code>, etc.).</p>
</li>
</ul>
<p>That code works perfectly for both components, but there is too much duplicated plumbing behind both components.</p>
<h2>Extracting the common logic</h2>
<p>The next step was to extract the common logic for all components that implement a block DSL into a module so it can be reused:</p>
<pre><code class="language-ruby">module ContentBuffer
  def self.included(base)
    base.extend(ClassMethods)
  end

  module ClassMethods
    def content_buffer_key(key)
      define_method(:_content_buffer_key) { key }

      private :_content_buffer_key
    end
  end

  private

  def buffer_stack
    Thread.current[_content_buffer_key] ||= []
  end

  def capture_content(&amp;block)
    buffer_stack.push([])
    result = block.call
    buffer = buffer_stack.pop

    buffer.empty? ? result.to_s : buffer.join
  end

  def append_to_buffer(html)
    buffer_stack.last &lt;&lt; html if buffer_stack.any?

    html
  end
end
</code></pre>
<p>A class-level setter handles the thread-local key name in the clients of the new module:</p>
<pre><code class="language-ruby">module Components
  module Table
    include ContentBuffer

    content_buffer_key :table_buffer_stack

    def table(&amp;block)
      content = capture_content(&amp;block)

      Components.load_template("table/_table.erb", binding)
    end

    def table_head(&amp;block)
      content = capture_content(&amp;block)
      result = Components.load_template("table/_head.erb", binding)

      append_to_buffer(result)
    end

    # other helpers omitted
  end
end
</code></pre>
<pre><code class="language-ruby">module Components
  module Modal
    include ContentBuffer

    content_buffer_key :modal_buffer_stack

    def modal(&amp;block)
      content = capture_content(&amp;block)

      Components.load_template("modal/_modal.erb", binding)
    end

    def modal_header(&amp;block)
      content = capture_content(&amp;block)
      result = Components.load_template("modal/_header.erb", binding)

      append_to_buffer(result)
    end

    # other helpers omitted
  end
end
</code></pre>
<p>Besides, extracting that logic into a module makes testing easier.</p>
<p>To do this, we first create a couple of test components:</p>
<pre><code class="language-ruby">module TestComponents
  module Container
    include ContentBuffer

    content_buffer_key :container_buffer_stack

    def container(&amp;block)
      capture_content(&amp;block)
    end

    def item(content)
      result = "&lt;item&gt;#{content}&lt;/item&gt;"

      append_to_buffer(result)
    end

    def group(&amp;block)
      content = capture_content(&amp;block)
      result = "&lt;group&gt;#{content}&lt;/group&gt;"

      append_to_buffer(result)
    end
  end

  module Widget
    include ContentBuffer

    content_buffer_key :widget_buffer_stack

    def widget(&amp;block)
      capture_content(&amp;block)
    end

    def widget_part(content)
      result = "&lt;part&gt;#{content}&lt;/part&gt;"

      append_to_buffer(result)
    end
  end
end
</code></pre>
<p>Then we add the following test examples:</p>
<pre><code class="language-ruby">RSpec.describe ContentBuffer do
  let(:component) do
    Class.new do
      include TestComponents::Container
    end.new
  end

  after do
    Thread.current[:container_buffer_stack] = nil
  end

  describe "block return value fallback" do
    it "returns the block return value when no items are appended" do
      result = component.container { "plain text" }

      expect(result).to eq("plain text")
    end

    it "converts non-string return values to string" do
      result = component.container { 42 }

      expect(result).to eq("42")
    end
  end

  describe "buffered content" do
    it "joins appended items" do
      result = component.container do
        component.item("one")
        component.item("two")
        component.item("three")
      end

      expect(result).to eq("&lt;item&gt;one&lt;/item&gt;&lt;item&gt;two&lt;/item&gt;&lt;item&gt;three&lt;/item&gt;")
    end

    it "ignores the block return value when items are appended" do
      result = component.container do
        component.item("buffered")
        "ignored"
      end

      expect(result).to eq("&lt;item&gt;buffered&lt;/item&gt;")
    end
  end

  describe "nested captures" do
    it "inner group does not leak into outer container" do
      result = component.container do
        component.item("before")

        component.group do
          component.item("nested")
        end

        component.item("after")
      end

      expect(result).to eq("&lt;item&gt;before&lt;/item&gt;&lt;group&gt;&lt;item&gt;nested&lt;/item&gt;&lt;/group&gt;&lt;item&gt;after&lt;/item&gt;")
    end
  end

  describe "isolation between different buffer keys" do
    let(:other_component) do
      Class.new do
        include TestComponents::Widget
      end.new
    end

    after do
      Thread.current[:widget_buffer_stack] = nil
    end

    it "two components with different keys do not interfere" do
      widget_result = nil

      container_result = component.container do
        component.item("a")

        widget_result = other_component.widget do
          other_component.widget_part("b")
        end

        component.item("c")
      end

      expect(container_result).to eq("&lt;item&gt;a&lt;/item&gt;&lt;item&gt;c&lt;/item&gt;")
      expect(widget_result).to eq("&lt;part&gt;b&lt;/part&gt;")
    end
  end
end
</code></pre>
<h2>The third block DSL component: alert</h2>
<p>The goal was to create an <code>alert</code> component that would allow consumers to write something like the following inside a single <code>&lt;%= %&gt;</code> tag:</p>
<pre><code class="language-ruby">alert(variant: :success) do
  alert_icon
  alert_title { "Payment processed successfully" }
  alert_description { "The transaction has been completed." }
  alert_actions do
    alert_action("View details", variant: :primary, htmx: { ... })
    alert_action("Dismiss", variant: :secondary, htmx: { ... })
  end
end
</code></pre>
<p>The <code>variant</code> should set the background color and the border, as well as the icon to display and its color, if <code>alert_icon</code> is included in the alert.</p>
<p>This poses a problem because <code>alert_icon</code>, if it receives no arguments, has to render the default icon according to the variant defined in the parent. However, the buffer does not allow a child to access data from the parent.</p>
<p>Furthermore, the alert's icon and actions are not rendered in the same block as the title and description. The template does not receive a single flat content string to print; instead, it needs the icon and actions separately, as independent variables, to place them in their respective spots. The buffer at this point only returns a plain string with everything concatenated and is incapable of separating that out.</p>
<p>The code that solves these problems is the following:</p>
<pre><code class="language-ruby">def context_stack
  Thread.current[:"#{_content_buffer_key}_context"] ||= []
end

def set_context(metadata)
  context_stack.push(metadata)
end

def reset_context
  context_stack.pop
end

def current_context
  context_stack.last || {}
end
</code></pre>
<p>Therefore, the <code>ContentBuffer</code> module now has two main parts:</p>
<ul>
<li><p><strong>Buffer</strong>: a linear, append-only stream of HTML fragments from children.</p>
</li>
<li><p><strong>Context</strong>: a shared hash the parent seeds, that children can read from and write into.</p>
</li>
</ul>
<blockquote>
<p>The context key is derived from the same <code>_content_buffer_key</code> the buffer uses, with a <code>_context</code> suffix appended. This way, each component that includes <code>ContentBuffer</code> ends up with its own isolated buffer stack and context stack.</p>
</blockquote>
<p>The context uses a stack, rather than a single hash, for the same reason the buffer does: a component nested inside another cannot overwrite the parent's context.</p>
<p>The implementation of the <code>alert</code> component, slightly simplified, is as follows:</p>
<pre><code class="language-ruby">module Components
  module Alert
    include ContentBuffer

    content_buffer_key :alert_buffer_stack

    VARIANTS = {
      success: {
        ...
      },
      error: {
        ...
      },
    }.freeze

    def alert(variant:, &amp;block)
      set_context(variant:, icon: nil, actions: nil)

      content = capture_content(&amp;block)
      alert_icon = current_context[:icon]
      alert_actions = current_context[:actions]

      reset_context

      Components.load_template("alert/_alert.erb", binding)
    end

    def alert_icon(icon_name = nil)
      variant = current_context[:variant]

      current_context[:icon] = icon(icon_name || VARIANTS.dig(variant, :icon_name))

      ""
    end

    def alert_title(&amp;block)
      content = capture_content(&amp;block)
      result = Components.load_template("alert/_title.erb", binding)

      append_to_buffer(result)
    end

    def alert_description(&amp;block)
      content = capture_content(&amp;block)
      result = Components.load_template("alert/_description.erb", binding)

      append_to_buffer(result)
    end

    def alert_actions(&amp;block)
      content = capture_content(&amp;block)

      current_context[:actions] = Components.load_template("alert/_actions.erb", binding)

      ""
    end

    def alert_action(text, **options)
      result = button(text, **options)

      append_to_buffer(result)
    end
  end
end
</code></pre>
<p>Two points to highlight here:</p>
<ul>
<li><p><code>alert_icon</code> and <code>alert_actions</code> return an empty string. Instead of writing to the buffer, they store their rendered HTML in a named slot in the context (<code>:icon</code> and <code>:actions</code> respectively). The parent <code>alert</code> method reads those slots into its own local variables after running the block, and passes them to the template as independent variables.</p>
</li>
<li><p><code>alert_title</code> and <code>alert_description</code> write to the buffer exactly like table and modal children do, so they end up in the concatenated content.</p>
</li>
</ul>
<p>A single alert uses both mechanisms: title and description go into the concatenated content, icon and actions into named slots.</p>
<p>Next, we create a couple more test components:</p>
<pre><code class="language-ruby">module TestComponents
  # other code omitted

  module Panel
    include ContentBuffer

    content_buffer_key :panel_buffer_stack

    def panel(variant:, &amp;block)
      set_context(variant:, icon: nil)

      content = capture_content(&amp;block)
      icon = current_context[:icon]

      reset_context

      "&lt;panel variant='#{variant}' icon='#{icon || "none"}'&gt;#{content}&lt;/panel&gt;"
    end

    def panel_icon
      variant = current_context[:variant]

      current_context[:icon] = "icon-for-#{variant}"

      ""
    end

    def panel_body(content)
      append_to_buffer("&lt;body&gt;#{content}&lt;/body&gt;")
    end

    def read_current_context
      current_context
    end
  end

  module Banner
    include ContentBuffer

    content_buffer_key :banner_buffer_stack

    def banner(variant:, &amp;block)
      set_context(variant:)

      content = capture_content(&amp;block)

      reset_context

      "&lt;banner variant='#{variant}'&gt;#{content}&lt;/banner&gt;"
    end

    def read_current_context
      current_context
    end
  end
end
</code></pre>
<p>And we add the following test examples:</p>
<pre><code class="language-ruby">RSpec.describe ContentBuffer do
  # other code omitted

  describe "context stack" do
    let(:panel_component) do
      Class.new do
        include TestComponents::Panel
      end.new
    end
    let(:banner_component) do
      Class.new do
        include TestComponents::Banner
      end.new
    end

    after do
      Thread.current[:panel_buffer_stack] = nil
      Thread.current[:panel_buffer_stack_context] = nil
      Thread.current[:banner_buffer_stack] = nil
      Thread.current[:banner_buffer_stack_context] = nil
    end

    it "returns empty hash when no context is set" do
      expect(panel_component.read_current_context).to eq({})
    end

    it "sets and clears context" do
      inside_context = nil

      panel_component.panel(variant: :success) do
        inside_context = panel_component.read_current_context
      end

      expect(inside_context).to eq(variant: :success, icon: nil)
      expect(panel_component.read_current_context).to eq({})
    end

    it "nests contexts" do
      nested_context = nil
      outer_context_after = nil

      panel_component.panel(variant: :info) do
        panel_component.panel(variant: :error) do
          nested_context = panel_component.read_current_context
        end

        outer_context_after = panel_component.read_current_context
      end

      expect(nested_context).to eq(variant: :error, icon: nil)
      expect(outer_context_after).to eq(variant: :info, icon: nil)
    end

    it "isolates context between different buffer keys" do
      panel_context = nil
      banner_context = nil

      panel_component.panel(variant: :success) do
        banner_component.banner(variant: :error) do
          panel_context  = panel_component.read_current_context
          banner_context = banner_component.read_current_context
        end
      end

      expect(panel_context).to eq(variant: :success, icon: nil)
      expect(banner_context).to eq(variant: :error)
    end
  end
end
</code></pre>
<h2>The full ContentBuffer module</h2>
<p>Putting it all together, the final <code>ContentBuffer</code> module looks like this:</p>
<pre><code class="language-ruby">module ContentBuffer
  def self.included(base)
    base.extend(ClassMethods)
  end

  module ClassMethods
    def content_buffer_key(key)
      define_method(:_content_buffer_key) { key }

      private :_content_buffer_key
    end
  end

  private

  def buffer_stack
    Thread.current[_content_buffer_key] ||= []
  end

  def capture_content(&amp;block)
    buffer_stack.push([])
    result = block.call
    buffer = buffer_stack.pop

    buffer.empty? ? result.to_s : buffer.join
  end

  def append_to_buffer(html)
    buffer_stack.last &lt;&lt; html if buffer_stack.any?

    html
  end

  def context_stack
    Thread.current[:"#{_content_buffer_key}_context"] ||= []
  end

  def set_context(metadata)
    context_stack.push(metadata)
  end

  def reset_context
    context_stack.pop
  end

  def current_context
    context_stack.last || {}
  end
end
</code></pre>
<h2>Possible improvements</h2>
<p>The module is simple and covers every block DSL component I have needed so far, but there are still a few rough edges worth calling out before wrapping up, although it is not meant to be a thorough list:</p>
<ol>
<li><p><strong>Exception safety</strong>: <code>capture_content</code> pushes and pops the buffer stack by hand, and <code>alert</code> does the same with <code>set_context</code> and <code>reset_context</code>. If the block raises in between, the stack keeps the orphaned frame. A <code>begin/ensure</code> around each pair fixes both.</p>
</li>
<li><p><strong>Explicit buffer-vs-context contract</strong>: Writing to the buffer means returning a string; writing to a slot means returning <code>""</code> and assigning into the context by hand. A helper like <code>with_slot(slot_name) { ... }</code> would make the slot write the block's explicit purpose instead of an empty-string side effect.</p>
</li>
<li><p><strong>Typed context slots</strong>: The slots <code>:icon</code>, <code>:actions</code>, and <code>:variant</code> are untyped hash keys, so a typo silently returns <code>nil</code>. A <code>context_slots</code> class macro, alongside <code>content_buffer_key</code>, would declare each component's slots and catch typos.</p>
</li>
<li><p><strong>Rename the module</strong>: <code>ContentBuffer</code> fit when there was only a buffer. With the context stack in place, it should have a name that fits better.</p>
</li>
<li><p><strong>Extend module API</strong>: add a method to reset the values stored in the tests, instead of assigning <code>nil</code> in an <code>after</code> block.</p>
</li>
</ol>
<h2>Conclusion</h2>
<p>Building your own component system from scratch forces you to face problems that frameworks like Rails solve for you. Once you solve them yourself, even in a small way, they stop feeling like magic.</p>
<p>The <code>ContentBuffer</code> module is not trying to be a general-purpose abstraction. It only has to work for the components that use it today, and that is why it stays small.</p>
<p>In exchange, what you get is Rails-like ergonomics on top of ERB in Sinatra, with no extra dependencies. There is still room for improvement, but the core is simple and easy to test.</p>
<p>If you have an alternative approach to this problem, feel free to share it with me.</p>
<p>Thank you for reading, and see you in the next one!</p>
]]></content:encoded></item><item><title><![CDATA[The one about multi-API support in legacy code]]></title><description><![CDATA[In a previous post I mentioned I had been working on replicating some functionality for collecting beneficiaries data in an old SPA.
The code in that application was quite legacy and highly coupled to]]></description><link>https://davidmontesdeoca.dev/the-one-about-multi-api-support-in-legacy-code</link><guid isPermaLink="true">https://davidmontesdeoca.dev/the-one-about-multi-api-support-in-legacy-code</guid><category><![CDATA[Ruby]]></category><category><![CDATA[sinatrarb]]></category><category><![CDATA[legacy code]]></category><category><![CDATA[spa]]></category><category><![CDATA[api]]></category><category><![CDATA[proxy]]></category><category><![CDATA[refactoring]]></category><category><![CDATA[Testing]]></category><category><![CDATA[React]]></category><dc:creator><![CDATA[David Montesdeoca]]></dc:creator><pubDate>Mon, 23 Mar 2026 20:14:14 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/642d433eeaad3d174f737099/30c7fd89-597f-4546-9b33-3814549dd7b3.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In a <a href="/the-one-about-a-new-project-working-for-an-american-fintech">previous post</a> I mentioned I had been working on replicating some functionality for collecting beneficiaries data in an old SPA.</p>
<p><strong>The code in that application was quite legacy and highly coupled to an existing API.</strong> It was poorly tested, and even those tests did not have a make command to run locally nor were they running in the CI pipeline.</p>
<p>It consisted of a <a href="https://sinatrarb.com/">Sinatra</a> application that acts as a proxy between a React frontend and a backend API. For a long time, this proxy only knew about one API, so everything was built around that single dependency.</p>
<p>Then I needed to add support for a new API. But first, I had to isolate anything that was coupled to the existing API, thus making room for the upcoming changes.</p>
<blockquote>
<p>In the examples below, I have omitted code that is unnecessary to understand the process, such as logging and observability instrumentation.</p>
</blockquote>
<h2>The initial setup</h2>
<p>The backend application is very simple. The entry point is <code>config.ru</code>, which maps routes to different Rack apps via a Dispatcher:</p>
<pre><code class="language-ruby"># system/infrastructure/dispatcher.rb
module Infrastructure
  Dispatcher = Rack::Builder.app do
    map '/' do
      run System::Server
    end

    map '/api' do
      run System::Proxy
    end
  end
end
</code></pre>
<p>The server handles static file serving for the React SPA:</p>
<pre><code class="language-ruby"># system/server.rb
module System
  class Server &lt; Sinatra::Base
    set root: File.expand_path('..', __dir__)

    not_found do
      render_static_file
    end

    get '/' do
      render_static_file
    end

    private

    def render_static_file
      File.read(File.join('public', 'index.html'))
    end
  end
end
</code></pre>
<p>The proxy delegates to a request handler, where the actual API proxying happens:</p>
<pre><code class="language-ruby"># system/request_handler.rb
module System
  class RequestHandler
    UNAUTHORIZED_CODE = 401
    ALLOWED_PATHS = %w(
      /beneficiary_collections
    )
    ALLOWED_METHODS = %w(GET PUT PATCH POST)
    HEADERS = { 'Content-Type' =&gt; 'application/json' }

    def initialize(env)
      @request = Rack::Request.new(env)
    end

    def call
      return render(UNAUTHORIZED_CODE, { error: 'Unauthorized' }.to_json) if invalid_path &amp;&amp; invalid_request_method

      response = # HTTParty logic

      render(response.code, response.body)
    end

    private

    def invalid_path
      !ALLOWED_PATHS.include?(path)
    end

    def invalid_request_method
      !ALLOWED_METHODS.include?(@request.request_method)
    end

    def render(status, body)
      [status, HEADERS, [body]]
    end

    def api_url
      ENV['API_BASE_URL']
    end

    # other private methods omitted
  end
end
</code></pre>
<p>If you look closely at the <code>call</code> method, the guard clause uses <code>&amp;&amp;</code> instead of <code>||</code>. This means it only returns <em>unauthorized</em> when both the path and the method are invalid, not when either one is. This was a bug that was present from the start and that I would fix later as part of this process.</p>
<p>Notice that <code>api_url</code> always returns the same environment variable regardless of the request path. The whole request handler assumes there is only one API.</p>
<h2>First changes: routing through the proxy</h2>
<p>Here is something I had not noticed before: <strong>in the local development environment, the frontend was bypassing the proxy entirely</strong>. The webpack dev server was configured to send API requests directly to the API stub.</p>
<p>The webpack dev server configuration was as follows:</p>
<pre><code class="language-javascript">// client/webpack/webpack.development.js
const API_BASE_URL = process.env.API_BASE_URL || 'http://beneficiary_collections_api:5300';

module.exports = {
  // ...
  devServer: {
    host: '0.0.0.0',
    port: 3001,
    allowedHosts: 'all',
    historyApiFallback: true,
    proxy: {
      '/api': {
        target: API_BASE_URL,
        pathRewrite: { '^/api': '' }
      },
      // ...
    }
  }
}
</code></pre>
<p>The <code>pathRewrite</code> stripped the <code>/api</code> prefix and sent requests straight to the stub. This meant the Sinatra proxy was never involved in local development. Consequently, <strong>you cannot test routing if you are skipping the proxy</strong>.</p>
<p>To fix this, I added a backend service to <code>docker-compose.yml</code> and renamed <code>app</code> to <code>frontend</code>:</p>
<pre><code class="language-yaml">services:
  frontend:
    ports:
      - 3001:3001
    depends_on:
      - backend
    command: npm start

  backend:
    ports:
      - 8080:8080
    depends_on:
      - beneficiary_collections_api
    command: bundle exec rerun --dir . --pattern "**/*.{rb}" --background -- rackup --port=8080 -o 0.0.0.0 config.ru

  beneficiary_collections_api:
    build:
      context: ./stubs/beneficiary_collections_api
    ports:
      - 5300:5300
    command: bundle exec rerun --dir . --background -- rackup --port=5300 -o 0.0.0.0 config.ru
</code></pre>
<p>I installed the <a href="https://github.com/alexch/rerun">rerun gem</a> so the backend application would automatically restart when any Ruby file changed.</p>
<blockquote>
<p>Note that the page still needs to be reloaded in the browser. Until the backend is fully reloaded, requests will return a 504 error.</p>
</blockquote>
<p>Then I updated the webpack dev server to point at the backend instead of the API stub directly:</p>
<pre><code class="language-javascript">// client/webpack/webpack.development.js
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend:8080';

module.exports = {
  // ...
  devServer: {
    host: '0.0.0.0',
    port: 3001,
    allowedHosts: 'all',
    historyApiFallback: true,
    proxy: {
      '/api': {
        target: BACKEND_URL
      },
      // ...
    }
  }
}
</code></pre>
<p>No more <code>pathRewrite</code>. The <code>/api</code> prefix is now kept as-is, and the backend proxy handles it from there. This matches how the application works in staging and production.</p>
<p>Finally, I refactored the scarce tests to route them through the <code>Dispatcher</code> instead of testing the proxy in isolation, and added the make command to be able to run those tests locally:</p>
<pre><code class="language-ruby"># spec/requests/api/proxy_spec.rb
RSpec.describe 'Proxy' do
  include Rack::Test::Methods

  def app
    Infrastructure::Dispatcher
  end

  context 'when beneficiary_collections' do
    let(:base_url) { 'https://beneficiary-collections.lol' }

    before do
      allow(ENV).to receive(:[]).with('API_BASE_URL').and_return(base_url)

      stub_request(:get, "#{base_url}/beneficiary_collections")
        .to_return(status: 200, body: { message: 'Success' }.to_json)

      stub_request(:get, "#{base_url}/beneficiary_collections/invalid")
        .to_return(status: 401, body: { message: 'Unauthorized' }.to_json)
    end

    context 'when the URL is allowed' do
      it 'returns a 200 status code' do
        get '/api/beneficiary_collections'

        expect(last_response.status).to eq(200)
      end
    end

    context 'when the URL is not allowed' do
      it 'returns a 401 status code' do
        get '/api/beneficiary_collections/invalid'

        expect(last_response.status).to eq(401)
      end
    end
  end
end
</code></pre>
<h2>Isolating the old API</h2>
<p>I applied a set of changes in both the frontend and the backend.</p>
<p>The key backend change was making <code>api_url</code> conditional in the request handler instead of returning always the same environment variable (properly renamed below):</p>
<pre><code class="language-ruby"># system/request_handler.rb
def api_url
  if path.start_with?('/beneficiary_collections')
    ENV['BENEFICIARY_COLLECTIONS_API_BASE_URL']
  end
end
</code></pre>
<p>The tests were properly updated, too, adding a specific context for the beneficiary collections API.</p>
<p>On the frontend side, the template that renders the beneficiary collection form was extracted from the page where it was being rendered, so it could be easily duplicated later. I also updated the existing test suite using <a href="https://testing-library.com/docs/react-testing-library/intro/">React Testing Library</a>, adding a new context to ensure the extracted components still behaved correctly in isolation.</p>
<h2>Adding the new API</h2>
<p>With the groundwork done, adding the new API was straightforward.</p>
<p>On the React side, a new route was added:</p>
<pre><code class="language-javascript">// client/src/pages/App/App.js
import Home from 'pages/Home';
import CollectionRequestsHome from 'pages/CollectionRequestsHome';

function App() {
  return (
    &lt;Router&gt;
      &lt;ContextWrapper&gt;
        &lt;Switch&gt;
          &lt;Route exact path="/" component={Home} /&gt;
          &lt;Route exact path="/collection_requests" component={CollectionRequestsHome} /&gt;
        &lt;/Switch&gt;
      &lt;/ContextWrapper&gt;
    &lt;/Router&gt;
  );
}

export default App;
</code></pre>
<p>All components were duplicated and adapted to the collection requests form, including the endpoints specific to the new API. Every change on the frontend was accompanied by new unit and integration tests.</p>
<p>On the backend, the request handler received several improvements:</p>
<ul>
<li><p>All constants are frozen.</p>
</li>
<li><p>The status code for invalid requests was changed from 401 to 404, which is more semantically correct.</p>
</li>
<li><p>The validation methods became proper predicate methods.</p>
</li>
<li><p>An attribute reader was added for the request.</p>
</li>
<li><p>The <code>api_url</code> method now routes based on the request path prefix.</p>
</li>
</ul>
<pre><code class="language-ruby"># system/request_handler.rb
module System
  class RequestHandler
    NOT_FOUND_STATUS_CODE = 404
    ALLOWED_PATHS = %w[
      /beneficiary_collections
      /collection_requests
    ].freeze
    ALLOWED_METHODS = %w[GET PUT PATCH POST].freeze
    HEADERS = { 'Content-Type' =&gt; 'application/json' }

    def initialize(env)
      @request = Rack::Request.new(env)
    end

    def call
      if invalid_path_info? || invalid_request_method?
        return render(NOT_FOUND_STATUS_CODE, { error: 'Not found' }.to_json)
      end

      response = # HTTParty logic

      render(response.code, response.body)
    end

    private

    attr_reader :request

    def invalid_path_info?
      !(request.path_info.match?(Regexp.union(ALLOWED_PATHS)))
    end

    def invalid_request_method?
      !(ALLOWED_METHODS.include?(request.request_method))
    end

    def render(status, body)
      [status, HEADERS, [body]]
    end

    def api_url
      if path.start_with?('/beneficiary_collections')
        ENV['BENEFICIARY_COLLECTIONS_API_BASE_URL']
      elsif path.start_with?('/collection_requests')
        ENV['COLLECTION_REQUESTS_API_BASE_URL']
      end
    end

    # other private methods omitted
  end
end
</code></pre>
<p>The server needed a new route too:</p>
<pre><code class="language-ruby"># system/server.rb
get '/collection_requests/' do
  render_static_file
end
</code></pre>
<p>That trailing slash on <code>get '/collection_requests/'</code> will come back to haunt me.</p>
<p>A new stub application was added for the collection requests API, and <code>docker-compose.yml</code> was updated to include it:</p>
<pre><code class="language-yaml">services:
  collection_requests_api:
    build:
      context: ./stubs/collection_requests_api
    ports:
      - 5301:5301
    command: bundle exec rerun --dir . --background -- rackup --port=5301 -o 0.0.0.0 config.ru
</code></pre>
<p>A new context block for the collection requests API was added to the proxy test, mirroring the beneficiary collections one.</p>
<h2>The trailing slash problem</h2>
<p>The code was working perfectly locally. Tests were green. It got merged and deployed to staging. And then <strong>the collection requests form did not render</strong>.</p>
<p>The issue turned out to be a trailing slash added to the route, while React router navigated to <code>/collection_requests</code>, without the trailing slash.</p>
<p>Sinatra does not treat these the same way by default, and it is well documented in <a href="https://sinatrarb.com/faq.html#slash">their FAQ section</a>.</p>
<p>The fix was to make the route accept both:</p>
<pre><code class="language-ruby"># system/server.rb
get '/collection_requests/?' do
  render_static_file
end
</code></pre>
<p>The <code>/?</code> at the end makes the trailing slash optional. The <code>not_found</code> handler was also restored so that unknown routes would still serve the SPA. It returns a 404 status code so the React app can show its own not found page.</p>
<p>On the frontend, the webpack dev server configuration also changed. The <code>historyApiFallback</code> option was replaced with a custom middleware that validates routes and handles trailing slashes:</p>
<pre><code class="language-javascript">// client/webpack/webpack.development.js
const VALID_PATHS = ['/', '/collection_requests'];

module.exports = {
  historyApiFallback: false,
  setupMiddlewares: (middlewares, devServer) =&gt; {
    devServer.app.get('*', (req, res, next) =&gt; {
      if (req.headers.accept?.includes('text/html')) {
        const normalizedPath = req.path.replace(/\/$/, '') || '/';

        if (!VALID_PATHS.includes(normalizedPath)) {
          res.status(404);
        }

        req.url = '/index.html';
      }

      next();
    });

    return middlewares;
  },
</code></pre>
<p>Not very elegant, but it works.</p>
<p>The React app also got a catch-all route for unknown paths, with a <code>NotFound</code> component:</p>
<pre><code class="language-javascript">// client/src/pages/App/App.js
import NotFound from 'pages/NotFound';

// ...
&lt;Route path="*" component={NotFound} /&gt;
</code></pre>
<p>I also added minimal testing for the server to cover both with and without trailing slash:</p>
<pre><code class="language-ruby"># spec/requests/server_spec.rb
RSpec.describe 'Server' do
  include Rack::Test::Methods

  def app
    Infrastructure::Dispatcher
  end

  before do
    allow(File).to receive(:read).and_call_original
    allow(File).to receive(:read).with(File.join('public', 'index.html')).and_return('html content')
  end

  context 'when path is /' do
    it 'returns a 200 status code and HTML content' do
      get '/'

      expect(last_response.status).to eq(200)
      expect(last_response.headers['Content-Type']).to include('text/html')
    end
  end

  context 'when path is /collection_requests' do
    it 'returns a 200 status code and HTML content' do
      get '/collection_requests'

      expect(last_response.status).to eq(200)
      expect(last_response.headers['Content-Type']).to include('text/html')
    end
  end

  context 'when path is /collection_requests/' do
    it 'returns a 200 status code and HTML content' do
      get '/collection_requests/'

      expect(last_response.status).to eq(200)
      expect(last_response.headers['Content-Type']).to include('text/html')
    end
  end

  context 'when path is invalid' do
    it 'returns a 404 status code and HTML content' do
      get '/invalid'

      expect(last_response.status).to eq(404)
      expect(last_response.headers['Content-Type']).to include('text/html')
    end
  end
end
</code></pre>
<p>To prevent this from happening again, I added some make commands to run the application in production mode locally:</p>
<pre><code class="language-bash"># Makefile
.PHONY: prod/up prod/down prod/build

prod/up:
  docker compose -f docker-compose.prod.yml up

prod/down:
  docker compose -f docker-compose.prod.yml up

prod/build:
  docker compose -f docker-compose.prod.yml build
</code></pre>
<p>The dedicated docker-compose file is as follows:</p>
<pre><code class="language-yaml"># docker-compose.prod.yml
services:
  app:
    ports:
      - 8080:80
    depends_on:
      - beneficiary_collections_api
      - collection_requests_api
    environment:
      - NODE_ENV=production

  beneficiary_collections_api:
    build:
      context: ./stubs/beneficiary_collections_api
    ports:
      - 5300:5300
    volumes:
      - ./stubs/beneficiary_collections_api:/opt/stubs
    command: bundle exec rerun --dir . --background -- rackup --port=5300 -o 0.0.0.0 config.ru

  collection_requests_api:
    build:
      context: ./stubs/collection_requests_api
    ports:
      - 5301:5301
    volumes:
      - ./stubs/collection_requests_api:/opt/stubs
    command: bundle exec rerun --dir . --background -- rackup --port=5301 -o 0.0.0.0 config.ru
</code></pre>
<p>From now on, catching the kind of failure caused by trailing slashes locally is way easier.</p>
<h2>Conclusion</h2>
<p>Adding support for a new API provided an opportunity to harden a legacy application. We moved from a poorly tested setup that bypassed its own proxy locally to a robust environment with proper routing and test coverage.</p>
<p>The challenges I faced, from the logic bug in the guard clause to the trailing slash discrepancy, demonstrate why dev-to-production parity is essential. With these changes in place, the application is no longer tied to a single dependency, and the team can now develop and test new features with the confidence that "working locally" actually means "working in production."</p>
<p>Thank you for reading and see you in the next one!</p>
]]></content:encoded></item><item><title><![CDATA[The one about rendering and displaying code examples in ERB]]></title><description><![CDATA[I have been building a component showcase page for an internal design system, using Sinatra and ERB templates. The page renders each UI component visually and, right below it, offers a toggle to revea]]></description><link>https://davidmontesdeoca.dev/the-one-about-rendering-and-displaying-code-examples-in-erb</link><guid isPermaLink="true">https://davidmontesdeoca.dev/the-one-about-rendering-and-displaying-code-examples-in-erb</guid><category><![CDATA[Ruby]]></category><category><![CDATA[ERB]]></category><category><![CDATA[Design Systems]]></category><category><![CDATA[sinatrarb]]></category><category><![CDATA[dry]]></category><dc:creator><![CDATA[David Montesdeoca]]></dc:creator><pubDate>Tue, 24 Feb 2026 18:07:14 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/642d433eeaad3d174f737099/4a71704b-945a-4785-a58e-65acf68f2309.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I have been building a component showcase page for an internal design system, using <a href="https://sinatrarb.com/">Sinatra</a> and <a href="https://ruby-doc.org/stdlib/libdoc/erb/rdoc/ERB.html">ERB</a> templates. The page renders each UI component visually and, right below it, offers a toggle to reveal the source code that produced it.</p>
<h2>The problem: code duplication</h2>
<p>The naive approach looks like this:</p>
<pre><code class="language-erb">&lt;div class="example"&gt;
  &lt;%= primary_button "Button" %&gt;
&lt;/div&gt;

&lt;%= erb :_toggle_code, locals: { code: 'primary_button "Button"' } %&gt;
</code></pre>
<p>The first block calls the Ruby helper to render the actual button. The second block passes the same call as a string to a partial that displays it:</p>
<pre><code class="language-erb">&lt;code&gt;&lt;%= code %&gt;&lt;/code&gt;
</code></pre>
<p>This works, but the code is duplicated. Update one, forget the other, and they drift apart. With dozens of examples, that drift becomes inevitable.</p>
<h2>Single source of truth</h2>
<p>The idea is straightforward: store the code as a string, then <code>eval</code> it to render the component, and display the source.</p>
<p>I wrapped the logic in a helper module:</p>
<pre><code class="language-ruby">def render_code_example(code)
  eval(code.strip, binding)
end
</code></pre>
<blockquote>
<p>Disclaimer: this is an internal tool, never use this pattern with untrusted or user-generated input.</p>
</blockquote>
<p>That worked for simple, single-line examples:</p>
<pre><code class="language-erb">&lt;div class="example"&gt;
  &lt;% example = 'primary_button "Button"' %&gt;

  &lt;%= render_code_example(example) %&gt;

  &lt;%= erb :_toggle_code, locals: { code: example } %&gt;
&lt;/div&gt;
</code></pre>
<p>However, I found that examples with multiple lines, like a fieldset containing several radio buttons, would only render the last one. <code>eval</code> returns the value of the last expression, so intermediate lines were lost:</p>
<pre><code class="language-erb">&lt;div class="example"&gt;
  &lt;%
    example = &lt;&lt;-CODE
      radio_input(name: "contact", value: "email", id: "contact_email", label: "Email")
      radio_input(name: "contact", value: "phone", id: "contact_phone", label: "Phone")
    CODE
  %&gt;

  &lt;fieldset&gt;
    &lt;legend&gt;Select your preferred contact method:&lt;/legend&gt;
    &lt;%= render_code_example(example) %&gt;
  &lt;/fieldset&gt;

  &lt;%= erb :_toggle_code, locals: { code: example } %&gt;
&lt;/div&gt;
</code></pre>
<p>The fix was to split the string into lines and eval each one independently:</p>
<pre><code class="language-ruby">def render_code_example(code)
  code.strip.split("\n").map { |line| eval(line.strip, binding) }.join("\n")
end
</code></pre>
<p>The method splits the string into lines, evaluates each one in the current <a href="https://ruby-doc.org/core/Binding.html">binding</a> (which has access to all the component helper methods), and joins the results. Each line is an independent Ruby expression that returns HTML.</p>
<p>The example is now defined once: <code>render_code_example</code> executes it, and the <code>_toggle_code</code> partial displays it.</p>
<p>That solved the rendering side, but the output code was not quite right. The <code>&lt;code&gt;</code> element collapses whitespace by default, so both radio buttons are shown in a single line:</p>
<pre><code class="language-erb">radio_input(name: "contact", value: "email", id: "contact_email", label: "Email") radio_input(name: "contact", value: "phone", id: "contact_phone", label: "Phone")
</code></pre>
<p>Wrapping the output in a <code>&lt;pre&gt;</code> tag preserves the line breaks:</p>
<pre><code class="language-erb">&lt;pre&gt;&lt;code&gt;&lt;%= code %&gt;&lt;/code&gt;&lt;/pre&gt;
</code></pre>
<p>That fixed the formatting, but introduced a new issue. The heredoc (<code>&lt;&lt;-CODE</code>) preserves the indentation from the ERB template, so the output code was rendered with leading spaces:</p>
<pre><code class="language-erb">            radio_input(name: "contact", value: "email", id: "contact_email", label: "Email")
            radio_input(name: "contact", value: "phone", id: "contact_phone", label: "Phone")
</code></pre>
<p>Switching to a <a href="https://docs.ruby-lang.org/en/master/syntax/literals_rdoc.html#here-document-literals">squiggly heredoc</a> (<code>&lt;&lt;~CODE</code>) stripped the common leading indentation automatically.</p>
<p>Finally, since the code string may contain HTML characters, the partial escapes it before rendering:</p>
<pre><code class="language-erb">&lt;pre&gt;&lt;code&gt;&lt;%= escape_code_example(code) %&gt;&lt;/code&gt;&lt;/pre&gt;
</code></pre>
<p>That new helper uses <code>Rack::Utils.escape_html</code> to handle the escaping.</p>
<p>This eliminated the duplication problem for all examples where each line is an independent Ruby expression.</p>
<h2>The new challenge: HTML components</h2>
<p>Then a new table component was needed, and instead of building a Ruby helper the first approach was a CSS-only component: plain HTML with custom styles.</p>
<pre><code class="language-html">&lt;table class="fw-table"&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Name&lt;/th&gt;
      &lt;th&gt;Email&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Alice&lt;/td&gt;
      &lt;td&gt;alice@example.com&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
</code></pre>
<p>You cannot <code>eval</code> HTML. It is not Ruby. A different approach was needed.</p>
<p>When ERB processes a template, it appends the rendered content to a string buffer. In Sinatra, that buffer is the instance variable <a href="https://github.com/sinatra/sinatra/blob/v4.1.1/lib/sinatra/base.rb#L866">@_out_buf</a>. The idea was to record the buffer's length before a block executes, let the block run, and then slice out everything that was appended during it:</p>
<pre><code class="language-ruby">def capture_html(&amp;block)
  buffer = @_out_buf
  pos = buffer.length
  yield
  dedent(buffer.slice!(pos..-1))
end
</code></pre>
<p>The <code>slice!</code> call is important: it removes the captured content from the buffer so it is not rendered twice.</p>
<p>There was one more problem. The captured HTML carries the indentation of the ERB template it lives in. Since the code display uses a <code>&lt;pre&gt;</code> tag, that indentation shows up as unwanted leading spaces.</p>
<p>A <code>dedent</code> helper strips the common leading whitespace, normalizing the output regardless of how deeply nested the ERB block was:</p>
<pre><code class="language-ruby">def dedent(text)
  margin = text.scan(/^[ \t]*(?=\S)/).map(&amp;:size).min || 0

  text.gsub(/^[ \t]{#{margin}}/, '').strip
end
</code></pre>
<p>With those two helpers in place, the ERB usage followed the same single-definition pattern:</p>
<pre><code class="language-erb">&lt;% example = capture_html do %&gt;
  &lt;table class="fw-table"&gt;
    &lt;thead&gt;
      &lt;tr&gt;
        &lt;th&gt;Name&lt;/th&gt;
        &lt;th&gt;Email&lt;/th&gt;
      &lt;/tr&gt;
    &lt;/thead&gt;
    &lt;tbody&gt;
      &lt;tr&gt;
        &lt;td&gt;Alice&lt;/td&gt;
        &lt;td&gt;alice@example.com&lt;/td&gt;
      &lt;/tr&gt;
    &lt;/tbody&gt;
  &lt;/table&gt;
&lt;% end %&gt;

&lt;%= example %&gt;

&lt;%= erb :_toggle_code, locals: { code: example } %&gt;
</code></pre>
<p>Write the HTML once. <code>capture_html</code> grabs it as a string. Output it for rendering, pass it for display.</p>
<h2>Unification: block detection</h2>
<p>The CSS-only table did not last long. It was eventually replaced with a Ruby block DSL, where the table is built through nested method calls:</p>
<pre><code class="language-ruby">table do
  table_head do
    table_row do
      table_header_cell("Name")
      table_header_cell("Email")
    end
  end
  table_body do
    table_row do
      table_cell("Alice")
      table_cell("alice@example.com")
    end
  end
end
</code></pre>
<p>Now <code>render_code_example</code> had to handle this too. But unlike the radio buttons from before, these lines are not independent expressions. They form a single <code>do...end</code> block, and evaluating <code>table do</code> in isolation is a syntax error.</p>
<p>The fix was to detect whether the code is a block expression and, if so, evaluate it as a whole instead of line by line:</p>
<pre><code class="language-ruby">def render_code_example(code)
  stripped = code.strip

  if block_expression?(stripped)
    eval(stripped, binding)
  else
    stripped.split("\n").map { |line| eval(line.strip, binding) }.join("\n")
  end
end

private

def block_expression?(code)
  code.match?(/\bend\z/)
end
</code></pre>
<p>The check is simple: if the code ends with the <code>end</code> keyword, treat it as a block. <code>\b</code> ensures it matches the whole word, and <code>\z</code> anchors to the end of the string.</p>
<p>In the ERB template, multi-line code uses a <a href="https://ruby-doc.org/core/doc/syntax/literals_rdoc.html#label-Here+Documents+-28Heredocs-29">heredoc</a> to keep things readable:</p>
<pre><code class="language-erb">&lt;%
  example = &lt;&lt;~CODE
    table do
      table_head do
        table_row do
          table_header_cell("Name")
          table_header_cell("Email")
        end
      end
      table_body do
        table_row do
          table_cell("Alice")
          table_cell("alice@example.com")
        end
      end
    end
  CODE
%&gt;

&lt;%= render_code_example(example) %&gt;

&lt;%= erb :_toggle_code, locals: { code: example } %&gt;
</code></pre>
<p>With this change, <code>capture_html</code> and <code>dedent</code> were no longer needed. Every component in the showcase now uses Ruby helpers, so the buffer-interception approach was removed entirely.</p>
<h2>Conclusion</h2>
<p>The core idea never changed: define each example once, then use it for both rendering and display. What evolved was the mechanism: from <code>eval</code> on single lines, to line-by-line splitting, to buffer interception for raw HTML, and finally to block detection when the Ruby DSL arrived. Each iteration solved a real problem introduced by the previous one.</p>
<p>The final code is straightforward, but it earned that simplicity one edge case at a time:</p>
<pre><code class="language-ruby">module Helpers
  module CodeExample
    def escape_code_example(*strings)
      Rack::Utils.escape_html(strings.join("\n\n"))
    end

    def render_code_example(code)
      stripped = code.strip

      if block_expression?(stripped)
        eval(stripped, binding)
      else
        stripped.split("\n").map { |line| eval(line.strip, binding) }.join("\n")
      end
    end

    private

    def block_expression?(code)
      code.match?(/\bend\z/)
    end
  end
end
</code></pre>
<p>If you have an interesting alternative approach to this problem, feel free to share it with me.</p>
<p>Thank you for reading and see you in the next one!</p>
]]></content:encoded></item><item><title><![CDATA[The one with a guide for configuring a proxy-email service]]></title><description><![CDATA[Disclaimer: This is not a sponsored post.

I recently registered the domain davidmontesdeoca.dev through sav.com. At the time of this writing, they offer a highly competitive price of \(4.99 for the f]]></description><link>https://davidmontesdeoca.dev/the-one-with-a-guide-for-configuring-a-proxy-email-service</link><guid isPermaLink="true">https://davidmontesdeoca.dev/the-one-with-a-guide-for-configuring-a-proxy-email-service</guid><category><![CDATA[ proxiedmail]]></category><category><![CDATA[proxy-email]]></category><category><![CDATA[dns]]></category><category><![CDATA[email]]></category><category><![CDATA[privacy]]></category><category><![CDATA[email-forwarding]]></category><category><![CDATA[Custom Domain]]></category><dc:creator><![CDATA[David Montesdeoca]]></dc:creator><pubDate>Tue, 27 Jan 2026 22:22:02 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1770663122646/050d08f7-f412-482a-8ed0-3abd2def15c2.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p>Disclaimer: This is not a sponsored post.</p>
</blockquote>
<p>I recently registered the domain <a href="https://davidmontesdeoca.dev">davidmontesdeoca.dev</a> through <a href="https://sav.com/">sav.com</a>. At the time of this writing, they offer a highly competitive price of \(4.99 for the first year, with a renewal rate of \)12.68 per year.</p>
<p>After configuring the blog DNS on Hashnode, I was surprised to find no option for setting up an email account. Since the feature was missing from the control panel, I contacted customer support via their web chat. They confirmed that they do not provide email services, though they did recommend several third-party email providers.</p>
<p>Until now, every time I registered a domain, I had access to a webmail service and had the possibility of creating an email account and multiple aliases for it. Usually, I would configure Gmail to receive and send emails for and from that email address too.</p>
<p>Ultimately, I chose <a href="https://proxiedmail.com/">ProxiedMail</a> as my proxy-email provider, a service that was not among the recommendations from the sav.com team. I was impressed by their <a href="https://proxiedmail.com/en/blog/using-email-directly-not-safe-anymore">security and privacy-focused approach</a>, the intuitive dashboard for configuring proxy-emails, and the availability of the following features within their free tier:</p>
<img src="https://github.com/user-attachments/assets/e1904a50-7ce7-4ef2-bbc9-b0889ff41844" alt="Free plan's features" style="display:block;margin:0 auto" />

<p>The following steps outline how I configured a proxy-email specifically for this post: <code>blog-post[@]davidmontesdeoca.dev</code>.</p>
<p>After registration, the dashboard allows you to create a new proxy-email:</p>
<img src="https://github.com/user-attachments/assets/3397dd3b-a69a-4b4d-a9a1-41ea0c2ce78e" alt="ProxiedMail's dashboard" style="display:block;margin:0 auto" />

<p>Since I am using a custom domain, I must first add it to the system:</p>
<img src="https://github.com/user-attachments/assets/67c961bc-4f57-4de6-8e70-25dde3d74afb" alt="Form to add a custom domain" style="display:block;margin:0 auto" />

<p>The verification process involves several steps:</p>
<img src="https://github.com/user-attachments/assets/62c7a42e-ec43-46f4-bdfc-823ec40dd2b2" alt="Step 1 of the domain configuration process: Domain ownership" style="display:block;margin:0 auto" />

<p>To begin the verification, navigate to sav.com: <code>My Domains</code> -&gt; <code>davidmontesdeoca.dev</code> -&gt; <code>Manage DNS</code> -&gt; <code>Custom DNS</code>.</p>
<p>The process for each record is identical: create a record in the domain provider dashboard using the data provided by the proxy-email service, then verify the record.</p>
<p>First, you must verify the domain ownership:</p>
<pre><code class="language-plaintext">Type: TXT
Name: davidmontesdeoca.dev
Value: proxiedmail-verification=&lt;verification code&gt;
</code></pre>
<blockquote>
<p>Using @ as the name works exactly the same way.</p>
</blockquote>
<p>In the Proxy settings, you must select <em>DNS Only</em>. Choosing <em>Enabled</em> will trigger the following error:</p>
<pre><code class="language-plaintext">Code 9004: This record type cannot be proxied.
</code></pre>
<p>Once ownership is verified, proceed to the MX record configuration:</p>
<img src="https://github.com/user-attachments/assets/99bfbfc7-5574-432f-b26c-7c7e73297c67" alt="Step 2 of the domain configuration process: MX record" style="display:block;margin:0 auto" />

<p>Add the following record at your domain provider:</p>
<pre><code class="language-plaintext">Type: MX
Name: davidmontesdeoca.dev
Value: mx.proxiedmail.com
Priority: 10
Proxy: DNS Only
</code></pre>
<p>Once the MX record is verified, proceed to the SPF record configuration:</p>
<img src="https://github.com/user-attachments/assets/67d87c8a-bbf4-4233-b7ec-3cbbcf65d5b1" alt="Step 3 of the domain configuration process: SPF record" style="display:block;margin:0 auto" />

<p>The process for adding this record is the same:</p>
<pre><code class="language-plaintext">Type: TXT
Name: davidmontesdeoca.dev
Value: v=spf1 include:spf.proxiedmail.com ~all
Proxy: DNS Only
</code></pre>
<p>Once the SPF record is verified, optionally proceed to the DKIM record configuration, which helps ensure your forwarded emails are not marked as spam by receiving servers:</p>
<img src="https://github.com/user-attachments/assets/70c47fa5-f771-47c4-8002-4aa55b5d81e3" alt="Step 4 of the domain configuration process: DKIM record" style="display:block;margin:0 auto" />

<p>To enable DKIM, add the following record:</p>
<pre><code class="language-plaintext">Type: CNAME
Name: dkim._domainkey.davidmontesdeoca.dev
Value: dkim._domainkey.pxdmail.com
Proxy: Enabled
</code></pre>
<p>While other verifications are near-instant, this one may take longer to propagate. Once applied, you will see the message: <em>You are all set</em>.</p>
<p>You are now ready to create your first proxy-email using your verified custom domain:</p>
<img src="https://github.com/user-attachments/assets/4f3aaa9a-5648-49d1-99b3-ad91a4fb8301" alt="Form to create a proxy-email" style="display:block;margin:0 auto" />

<p>A confirmation message will appear:</p>
<img src="https://github.com/user-attachments/assets/03b94771-a55c-419b-acd0-6f52382b9cf7" alt="Modal with message regarding the first proxy-email created" style="display:block;margin:0 auto" />

<p>With forwarding enabled by default, emails sent to the proxied email address will be received at your specified real email account:</p>
<img src="https://github.com/user-attachments/assets/314107c0-bcca-4542-bc8e-92321551e3af" alt="Test of the proxy-email sent" style="display:block;margin:0 auto" />

<img src="https://github.com/user-attachments/assets/a4f34959-a12f-45e6-9a12-fb3e0c90f297" alt="Details of the test of the proxy-email sent" style="display:block;margin:0 auto" />

<p>The email arrived in less than a minute:</p>
<img src="https://github.com/user-attachments/assets/6aee4af7-0f22-4034-8773-b42f17c690ae" alt="Test of the proxy-email received" style="display:block;margin:0 auto" />

<img src="https://github.com/user-attachments/assets/f0d95a67-8892-43b5-912e-0280149e3aa2" alt="Details of the test of the proxy-email received" style="display:block;margin:0 auto" />

<blockquote>
<p>This only works if the recipient is in the <em>To</em> field. During testing, emails where the recipient was placed in the <em>BCC</em> field were not delivered.</p>
</blockquote>
<p>In the dashboard you can see the total number of emails forwarded to the new address:</p>
<img src="https://github.com/user-attachments/assets/d2b5a18f-e720-497e-a4f1-d5348d78eeea" alt="The proxy-email just generated in the dashboard" style="display:block;margin:0 auto" />

<p>ProxiedMail also supports wildcard proxy email addresses:</p>
<img src="https://github.com/user-attachments/assets/fd4b23fe-99a2-4bba-a50e-ea6f5ddea207" alt="Form to create a wildcard proxy-email" style="display:block;margin:0 auto" />

<p>A modal explains the functionality:</p>
<img src="https://github.com/user-attachments/assets/2434e725-6137-4755-8b3c-dba4a8d44310" alt="Modal explaining what are the wildcard proxy-emails" style="display:block;margin:0 auto" />

<p>As with the other proxy-email, forwarding is active immediately:</p>
<img src="https://github.com/user-attachments/assets/0deb2b4c-d509-4fc0-abc1-63788d1ebce1" alt="Test of the wildcard proxy-email sent" style="display:block;margin:0 auto" />

<img src="https://github.com/user-attachments/assets/11d60c0b-933a-4bc8-a7c6-e8c79b14f57e" alt="Details of the test of the wildcard proxy-email sent" style="display:block;margin:0 auto" />

<p>The email sent to this email address was also delivered in less than a minute:</p>
<img src="https://github.com/user-attachments/assets/35b25286-2a66-464c-a137-8732281d2b7f" alt="Test of the wildcard proxy-email received" style="display:block;margin:0 auto" />

<img src="https://github.com/user-attachments/assets/f4ca4c53-3102-4889-8b2a-5a29f6905596" alt="Details of the test of the wildcard proxy-email received" style="display:block;margin:0 auto" />

<p>In the dashboard you can see the total number of emails forwarded to this new address too:</p>
<img src="https://github.com/user-attachments/assets/930a7e2d-e795-4490-8c9a-99a506d497e2" alt="The wildcard proxy-email just generated in the dashboard" style="display:block;margin:0 auto" />

<p>To conclude, I will highlight several other features available on the platform, both free and paid:</p>
<ul>
<li><p>Deleting proxy-emails is a paid feature. On the free plan, you can only disable unused email addresses:</p>
<img src="https://github.com/user-attachments/assets/80ff31a3-7b0e-41d5-8c89-547821bec104" alt="Modal asking the user to upgrade their plan to delete a proxy-email" style="display:block;margin:0 auto" />
</li>
<li><p>Hiding the forwarded email banner is also a paid feature:</p>
<img src="https://github.com/user-attachments/assets/861ffb90-3d87-4980-bc28-69fd9dc5fab6" alt="Modal with message telling the user that removing the forwarded email banner from the emails is a paid feature" style="display:block;margin:0 auto" />
</li>
<li><p>Adding an extra layer of securiy to your account with Two-Factor Authentication (2FA) is only available in a paid tier.</p>
</li>
<li><p>Storing a password to send emails via an alias did not work with Gmail during my testing; so most likely it requires a paid plan:</p>
<img src="https://github.com/user-attachments/assets/30fbbe33-abfc-4329-be23-0ccfbfa9cb3e" alt="Form to store a password for a proxy-email" style="display:block;margin:0 auto" />

<img src="https://github.com/user-attachments/assets/0f839ce8-e5f4-4d3c-af06-917fa2b971ac" alt="Form to generate a password for a proxy-email" style="display:block;margin:0 auto" />

<p>However, they offer a compelling feature for managing contacts:</p>
<img src="https://github.com/user-attachments/assets/e79cde22-7822-404c-95be-05ea3e5a1197" alt="Form to create a new contact" style="display:block;margin:0 auto" />

<img src="https://github.com/user-attachments/assets/68447ace-498f-45c7-8570-8197bf708ec5" alt="Explanation of the reverse alias process used for contacts" style="display:block;margin:0 auto" />

<p>This uses a reverse alias process, which worked perfectly in my tests (though the initial email was flagged as spam):</p>
<img src="https://github.com/user-attachments/assets/124f1636-3db3-4524-baf6-8cc5a3532170" alt="Test using reverse alias for a contact" style="display:block;margin:0 auto" />

<img src="https://github.com/user-attachments/assets/5b43e11d-195b-4856-9da4-99cc30bf4ddb" alt="Details of the test using reverse alias for a contact" style="display:block;margin:0 auto" />
</li>
<li><p>Using unique email addresses for every site where you register to track potential data leaks:</p>
<img src="https://github.com/user-attachments/assets/a3056df9-7ed1-4813-9344-c30f5623a5de" alt="Form to add websites where a given proxy-email has been used" style="display:block;margin:0 auto" />
</li>
<li><p>Adding context to remember the purpose of each proxy-email:</p>
<img src="https://github.com/user-attachments/assets/bb3eacf0-cda2-4037-9e72-8891072a6eb9" alt="Form to add a description to know where a given proxy-email has been used" style="display:block;margin:0 auto" />
</li>
<li><p>Identifying which proxy received a specific message with a reverse lookup:</p>
<img src="https://github.com/user-attachments/assets/e62e40d8-5f37-4498-b5ce-6cf3b5f8a79c" alt="Modal with the form for the proxy email reverse lookup" style="display:block;margin:0 auto" />

<img src="https://github.com/user-attachments/assets/4fe21f25-bd24-4f78-bd6c-8678d4beb6bb" alt="Modal with the result of the proxy email reverse lookup" style="display:block;margin:0 auto" /></li>
</ul>
<p>It is also worth noting that if you use the free tier, ProxiedMail requires you to verify your account via email every quarter. This is a simple process to confirm your account is still active and helps them manage inactive accounts:</p>
<blockquote>
<p>Thank you for using ProxiedMail.</p>
<p>You are receiving this as part of the quarterly verification of your ProxiedMail account. This verification allows us to track the active accounts and prevent spam.</p>
<p>All accounts that didn't pass the verification during the year could be frozen. That means you will stop receiving your messages when the account is frozen. Although, your account will be unfrozen on any activity in your account.</p>
<p>Additionally, verifying your account will give you some benefits as faster messages delivery and priority support.</p>
</blockquote>
<p>The service offers other functionalities beyond those listed here. I encourage you to see for yourself.</p>
<p>Although I have not tested every feature yet, what I have discovered while writing this post is impressive and definitely offers a significantly better user experience than relying on a provider's native webmail interface.</p>
<p>I really like ProxiedMail's approach, being a compelling alternative to traditional email setups. It is also significantly easier to configure.</p>
<p>Thank you for reading and see you in the next one!</p>
]]></content:encoded></item><item><title><![CDATA[The one about true flexible schedules]]></title><description><![CDATA[A while back, I wrote about my experience working remotely. I mentioned that, having seen the advantages of this way of working, today I would not consider accepting office-based or mandatory hybrid w]]></description><link>https://davidmontesdeoca.dev/the-one-about-true-flexible-schedules</link><guid isPermaLink="true">https://davidmontesdeoca.dev/the-one-about-true-flexible-schedules</guid><category><![CDATA[flexible schedule]]></category><category><![CDATA[asynchronous work]]></category><category><![CDATA[presenteeism]]></category><category><![CDATA[remote work]]></category><category><![CDATA[flexible hours]]></category><category><![CDATA[Productivity]]></category><category><![CDATA[burnout]]></category><category><![CDATA[Mental Health]]></category><category><![CDATA[deep work]]></category><category><![CDATA[work life balance]]></category><dc:creator><![CDATA[David Montesdeoca]]></dc:creator><pubDate>Tue, 30 Dec 2025 10:17:55 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1767090400402/9df4c3cc-e678-4a33-9295-21a00bfa4b5e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A while back, I wrote about <a href="/the-one-about-my-experience-working-remotely">my experience working remotely</a>. I mentioned that, having seen the advantages of this way of working, today I would not consider accepting office-based or mandatory hybrid work models.</p>
<p>But that's not all. As I see it, <strong>the perfect combination is fully remote work with true flexible hours</strong>. More on this below.</p>
<p>In my first job in Madrid, I worked from 9 to 7 for a year, with a 2-hour lunch break. Totally incomprehensible, considering I had my food already prepared and just had to heat it up in the microwave. 20 minutes was more than enough time; the rest was a complete waste of time.</p>
<p>Furthermore, during this time of year, with the winter schedule, I essentially did not see the light of day during workdays.</p>
<p>After the first year, the schedule changed to 9 to 6, with a 1-hour lunch break. A considerable improvement, but essentially, I still felt like I was wasting much more time than necessary.</p>
<p>Regardless of how productive I was being on any given day, I had to <em>be warming the chair in the office</em> whether I wanted to or not until quitting time.</p>
<p>Of course, I still missed being able to see the light of day for longer, because with the winter schedule, it gets dark shortly after 6 PM in this part of the world.</p>
<p>Little is said about the <strong>effect of sunlight on mood</strong>, which can even lead to <a href="https://en.wikipedia.org/wiki/Seasonal_affective_disorder">Seasonal Affective Disorder (SAD)</a>.</p>
<p>The situation I am describing is known as <strong>presenteeism</strong>, although it must be clarified that this term can refer to both virtual and physical presence.</p>
<p>By expecting you to always be available, a pressure is generated that can <strong>cause burnout</strong> in team members and make their <strong>productivity drop</strong>, achieving exactly the opposite of what is intended with that <em>culture of control</em>.</p>
<p>Unfortunately, this situation is widespread in the software industry. It has been normalized to such an extent that it is common to find job offers selling the idea of <strong>fake flexible hours</strong>.</p>
<p>For example, <a href="https://www.getmanfred.com/en">Manfred</a>, a Spanish company that takes improving job offers and recruitment processes seriously, <a href="https://www.getmanfred.com/en/job-offers/8228/ludus-backend-developer-dic25#horario">sells that idea in many of its offers</a> (this one available only in Spanish) when the company publishing the offer simply offers slight flexibility to start, but the workday remains rigid.</p>
<p>Certainly, that is better than having no flexibility at all. It may even be enough for people who, for instance, have to take their children to school.</p>
<p>However, true schedule flexibility comes when the company understands that with a <em>culture of objectives</em> (as opposed to the <em>culture of control</em> I mentioned earlier), the norm is to achieve better results and <strong>reduce burnout</strong>.</p>
<p>Of course, enjoying that privilege implies some obligations on your part:</p>
<ul>
<li><p>Demonstrate reliability, <strong>consistently delivering high-quality work on time</strong> to build the necessary trust.</p>
</li>
<li><p>Adopt <strong>asynchronous and proactive communication</strong> with your manager and the rest of the team, so they know when you will be available and your progress on the task you are currently working on. This ensures the team remains unblocked even when you are not online.</p>
</li>
<li><p>Be <strong>available during the team's core hours</strong> (e.g., from 10 am to 2 pm). This is usually a common requirement for companies, although my experience has always been that attending team meetings is sufficient.</p>
</li>
</ul>
<p>If you meet those obligations, I see no reason not to have the freedom to work when you feel your <strong>productivity will be highest</strong> or when you need blocks of <strong>absolute concentration without interruptions</strong>; whether that is early in the morning or late at night. It is simply adapting to your natural peak performance hours, which may not be the same every day.</p>
<p>The point is that the company considers you a responsible person who knows how to organize themselves in the best possible way. The main consequence of enjoying a flexible schedule is having a <strong>better work-life balance</strong> and <strong>greater autonomy</strong>.</p>
<p>You might work only 5 hours one day and 11 the next. You might even decide to work during the weekend to make up for time you could not dedicate during the week or to get ahead on work because you know that during the following week, you will not be able to dedicate the necessary time.</p>
<p>Perhaps another day you cannot attend a meeting because you have a medical appointment or need to take a break for a few hours because you do not feel well; perhaps you have to go grocery shopping or feel like going out for lunch with friends who are visiting for a few days.</p>
<p>There may be many and varied reasons, but they all come down to the same thing: the freedom to manage your life while fulfilling your work duties.</p>
<p>In the end, it is not about working less, but working better. And you, <em>are you still warming the seat?</em></p>
<p>Thank you for reading and see you in the next one!</p>
]]></content:encoded></item><item><title><![CDATA[The one about adding borders between columns in a grid]]></title><description><![CDATA[Here begins a new blog series where I plan to share useful tips I discover in my day-to-day development work.

Translating a Figma design into code for the new project I am currently working on, I enc]]></description><link>https://davidmontesdeoca.dev/the-one-about-adding-borders-between-columns-in-a-grid</link><guid isPermaLink="true">https://davidmontesdeoca.dev/the-one-about-adding-borders-between-columns-in-a-grid</guid><category><![CDATA[TIL]]></category><category><![CDATA[CSS]]></category><category><![CDATA[HTML]]></category><category><![CDATA[Tailwind CSS]]></category><category><![CDATA[Tailwind CSS]]></category><category><![CDATA[tailwind]]></category><dc:creator><![CDATA[David Montesdeoca]]></dc:creator><pubDate>Sat, 29 Nov 2025 11:58:57 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1764417508943/7deb490d-8389-4aed-bab2-c971d85968b6.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Here begins a new blog series where I plan to share useful tips I discover in my day-to-day development work.</p>
<hr />
<p>Translating a Figma design into code for the <a href="/the-one-about-a-new-project-working-for-an-american-fintech">new project I am currently working on</a>, I encountered a specific UI requirement: a container with 3 column sections separated by vertical borders. The catch was that these borders could not span the full height of the container; they needed inset padding at the top and bottom.</p>
<img src="https://github.com/user-attachments/assets/5bffa209-be51-4ffe-9512-741ab5af7b07" alt="Image" style="display:block;margin:0 auto" />

<p>Right away, it was clear this was a perfect use case for the wonderful CSS <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/grid">grid</a> property. However, exactly how to achieve those inset borders appearing between the columns was less obvious.</p>
<p>Thanks especially to the invaluable help found in <a href="https://www.reddit.com/r/webdev/comments/15w6ptw/how_to_get_borders_between_columns/">this Reddit thread</a> and <a href="https://www.youtube.com/watch?v=QjddVRthBrU">this video</a>, I landed on a clean solution involving the [:after](<a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/::after">https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/::after</a>) <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/Pseudo-elements">pseudo-element</a>.</p>
<p>I created a CodePen demonstrating this using plain HTML and CSS:</p>
<iframe height="300" style="width:100%" src="https://codepen.io/backpackerhh/embed/vEGWPxz?default-tab=html%2Cresult" frameborder="no">
  See the Pen <a href="https://codepen.io/backpackerhh/pen/vEGWPxz">
  Borders between columns in grid container using CSS</a> by David Montesdeoca (<a href="https://codepen.io/backpackerhh">@backpackerhh</a>) on <a href="https://codepen.io">CodePen</a>.
</iframe>

<p>In my specific case, I am not working with plain CSS but rather <a href="https://tailwindcss.com/">Tailwind CSS</a>, so I also created a CodePen adapting the solution to that framework:</p>
<iframe height="300" style="width:100%" src="https://codepen.io/backpackerhh/embed/NPNwJwP?default-tab=html%2Cresult" frameborder="no">
  See the Pen <a href="https://codepen.io/backpackerhh/pen/NPNwJwP">
  Borders between columns in grid container using Tailwind</a> by David Montesdeoca (<a href="https://codepen.io/backpackerhh">@backpackerhh</a>) on <a href="https://codepen.io">CodePen</a>.
</iframe>

<p>Regarding the Tailwind example, I want to highlight a specific decision. My initial intention was to define the utility classes inline directly on the HTML elements, as is standard Tailwind practice.</p>
<p>However, combining the [:not](<a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:not">https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:not</a>) and [:last-child](<a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:last-child">https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:last-child</a>) <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/Pseudo-classes">pseudo-classes</a> with the <code>:after</code> pseudo-element seemed to prevent Tailwind from generating the correct CSS rule. Therefore, I opted to use the <a href="https://tailwindcss.com/docs/functions-and-directives#apply-directive">@apply directive</a> instead and keep the HTML clean.</p>
<p>Finally, because this is a static design with exactly 3 columns, I briefly considered using [:before](<a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/::before">https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/::before</a>) and <code>:after</code> pseudo-elements only on the middle column. However, applying a right border to every item except the last one felt like a more robust approach. It ensures the solution remains scalable should a fourth column be added in the future.</p>
<p>There are surely thousands of ways to achieve this. If you have an interesting alternative method, feel free to share it with me.</p>
<p>Thank you for reading and see you in the next one!</p>
]]></content:encoded></item><item><title><![CDATA[The one about a new project working for an American fintech]]></title><description><![CDATA[At the beginning of this year I talked about the layoffs in the American fintech I work for as a contractor. That story is key to what comes next.
For a long time, the company was focused entirely on ]]></description><link>https://davidmontesdeoca.dev/the-one-about-a-new-project-working-for-an-american-fintech</link><guid isPermaLink="true">https://davidmontesdeoca.dev/the-one-about-a-new-project-working-for-an-american-fintech</guid><category><![CDATA[AI]]></category><category><![CDATA[#ai-tools]]></category><category><![CDATA[ai agents]]></category><category><![CDATA[mcp]]></category><category><![CDATA[claude-code]]></category><category><![CDATA[Ruby]]></category><category><![CDATA[sinatrarb]]></category><category><![CDATA[htmx]]></category><category><![CDATA[Frontend Development]]></category><category><![CDATA[frontend]]></category><category><![CDATA[Backend Development]]></category><category><![CDATA[backend]]></category><category><![CDATA[contractor]]></category><category><![CDATA[fintech]]></category><category><![CDATA[fintech software development]]></category><dc:creator><![CDATA[David Montesdeoca]]></dc:creator><pubDate>Wed, 29 Oct 2025 19:35:45 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1761157290326/87cd9574-3b97-4c30-a212-6a499d74e73b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>At the beginning of this year I talked about the <a href="/the-one-about-layoffs-in-an-american-fintech">layoffs</a> in the American fintech I work for as a contractor. That story is key to what comes next.</p>
<p>For a long time, the company was focused entirely on education. They finally <strong>decided to diversify, betting on the travel and hospitality industry</strong>. This new strategy kicked off a project at the start of Q3: building a new tool that allows the company's operators to manage the beneficiary network on behalf of clients.</p>
<p>To add some context, there is already a tool for managing beneficiaries still in use. The problem is, no one on the tech team is happy with it. The general consensus is that, looking back, poor design decisions were made.</p>
<p>In addition to that backend, there is an old SPA, developed with React, that collects the beneficiary data. These beneficiaries get an email notifying them that a client is missing some data to send a payment, which includes a link to a form. <em>Sounds like a scam, right?</em></p>
<p>The new tool consists of <strong>two brand-new applications</strong>: a REST API, developed with Sinatra, and an SPA, developed with React.</p>
<p>The backend application is being developed by the same team that has had the governance of the old beneficiaries application, in the <em>Transactional</em> area of the company.</p>
<p>Some members of other teams were selected to temporarily join that team, including myself, to gain context about the project and, in time, we will become part of the <strong>new beneficiaries team</strong> to take over the governance of all the relevant applications.</p>
<p>The new frontend application, however, is being developed by a team from Romania. They are not part of the <em>Transactional</em> area, a decision made due to a lack of internal staff and the very tight deadline we already had.</p>
<p>The idea was to get an MVP ready by the end of the quarter for a demo with a major German client, a big name in luxury resorts and 5-star hotels. Landing them would be a huge boost, though it was not a <em>sine qua non</em> condition for the project to continue.</p>
<p>From the beginning, the head of the <em>Transactional</em> area was uncomfortable with the fact that a team outside of the area was developing that application, so he soon started pulling strings to see what the options were for starting what became known as "<strong>the migration</strong>."</p>
<p>According to what I was told, the main reason they chose me for this new team was my willingness to work with frontend applications. In this company, almost everything is backend, and the little frontend that exists tends to be internal-use applications that do not get much love.</p>
<p>I was asked to do an analysis of how long it would take to do the <a href="/the-one-about-shortcuts-in-cursor-with-karabiner-elements">migration with AI</a> for the two options on the table:</p>
<ul>
<li><p>Being an SPA, move the logic into another existing SPA, even if the applications were unrelated.</p>
</li>
<li><p>Create a new <a href="https://martinfowler.com/articles/micro-frontends.html">microfrontend</a> with <strong>Sinatra + htmx</strong> that would be embedded in the <em>transactional registry</em> application, where other applications are already embedded.</p>
</li>
</ul>
<p>Initially, I had no doubt that the SPA option was the best one, especially if speed was the main goal. Of course, some adjustments would be needed. The two applications had been developed by different teams and the code was organized differently. For example, one managed state locally with <code>useState</code> while the other used Redux for managing the state of the whole application.</p>
<p>However, what they forgot to tell me was that a critical decision had already been made the previous year on future frontend development in the <em>Transactional</em> area: the preferred option is to create microfrontends with Sinatra + htmx.</p>
<p>The main reason being a general <strong>lack of frontend expertise</strong> among the area's developers. With the high rotation of team members in the same area of the company, they believed that sticking with React would just create friction for future changes or bug fixes.</p>
<p>Obviously, knowing that changed everything. <strong>The final goal was to build the microfrontend with Sinatra + htmx in any case</strong>.</p>
<p>Before making an estimate, I created a <em>proof of concept</em> UI with <a href="https://www.claude.com/product/claude-code">Claude Code</a> to generate a paginated list of beneficiaries, following the design of the current frontend application and the same approach as other microfrontends.</p>
<p>The experience was satisfying and frustrating in equal parts. Replicating the design and code from other applications was relatively easy (after iterating a few times), but it usually meant giving the AI agent too much context. As a result, it was very easy for it to end up doing too much or too little.</p>
<p>In the end, I made a rather <strong>naive estimate of 4 weeks to complete the migration</strong>. Even so, the head of the <em>Transactional</em> area thought it was too much time. The manager of the team later told me he had thought that with AI, it would be a matter of a couple of days.</p>
<p>Of course, I only consider that estimate naive from my current perspective, now that I have much more context on what actually needs to be done.</p>
<p>Shortly after that, I met with my manager for a couple of sessions where we did a much more precise estimation. We divided all the work into tasks with a very clear and reduced scope, and we added a conservative estimate of how long we might take for each one.</p>
<p>As a result of those two sessions, we <strong>estimated it would take a single person approximately 8 weeks</strong>. Of course, this is not a job where eight people could complete the work in one week. Many tasks simply cannot be parallelized.</p>
<p>With this new estimate, my manager got the buy-in we needed: if new features were strictly necessary, they would be added to the new React SPA, but during Q4, our team would focus entirely on the migration to the Ruby microfrontend.</p>
<p>In the meantime, I took on the task of replicating the existing functionality for collecting beneficiary information from the old SPA for the new beneficiaries application. This turned out to be a not-so-simple task as initially thought. I will talk about that in a future post.</p>
<p>Before the end of Q3, <strong>they confirmed that I was going to lead the migration project</strong>, which I have already been working on for a few weeks.</p>
<p>The project team consists of an engineering manager, a product manager, a designer, and three software engineers, including myself. The other two engineers will be more focused on backend tasks.</p>
<p>I started by defining the foundational tasks that will set the groundwork for our work in the coming months, while the product manager will take charge of defining the rest of the upcoming tasks.</p>
<p>I want to highlight that, as part of the detailed estimations we did, we factored in time to properly define the rules Claude Code would use, which are mostly already defined in a dedicated repository available company wide. We also dedicated time to learning how to get the most out of the AI with good prompting, though that is a continuous work in progress.</p>
<p>I have also been spending time deepening my knowledge of htmx and Tailwind, which I have little experience with so far.</p>
<p>At the moment, I am creating a <strong>design system</strong> in Ruby. I am basing it on components from our existing microfrontends and from the design in Figma, using Claude Code and <a href="https://modelcontextprotocol.io/docs/getting-started/intro">MCPs</a> like <a href="https://github.com/ChromeDevTools/chrome-devtools-mcp">chrome-devtools</a> and <a href="https://help.figma.com/hc/en-us/articles/32132100833559-Guide-to-the-Figma-MCP-server">Figma</a> for assistance.</p>
<p>We have plans to create a gem that will allow us to reuse the components created for this design system in the various microfrontends that already exist.</p>
<p>I do not expect to talk about the project in the coming months, at least until we have finished it. Then, I will write a retrospective post sharing what went well, and what could have gone better.</p>
<p>I would also like to share the technical details of how we solved the obstacles that we will surely find along the way.</p>
<p>I appreciate the trust placed in me, even as a contractor. I believe it is the result of a job well done.</p>
<p>Thank you for reading and see you in the next one!</p>
]]></content:encoded></item><item><title><![CDATA[The one about my experience at SNGULAR (year 1)]]></title><description><![CDATA[It has been a full year since I joined SNGULAR. In this post, I want to talk about my experience with them so far.
As I mentioned when I landed this job, working for a consulting firm was not in my pl]]></description><link>https://davidmontesdeoca.dev/the-one-about-my-experience-at-sngular-so-far</link><guid isPermaLink="true">https://davidmontesdeoca.dev/the-one-about-my-experience-at-sngular-so-far</guid><category><![CDATA[Consultancy]]></category><category><![CDATA[consulting]]></category><category><![CDATA[consulting firm]]></category><category><![CDATA[sngular]]></category><dc:creator><![CDATA[David Montesdeoca]]></dc:creator><pubDate>Sun, 28 Sep 2025 18:14:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1758645523723/d6c60567-fdd2-41fe-a032-d31f5549dd35.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>It has been a full year since I joined <a href="https://www.sngular.com/">SNGULAR</a>. In this post, I want to talk about my experience with them so far.</p>
<p>As I <a href="/the-one-about-my-new-job">mentioned when I landed this job</a>, working for a consulting firm was not in my plans when I started looking for a new job at the beginning of 2024.</p>
<p>For most of my professional career, I have worked in product companies. And that is not a coincidence. I really love the deep ownership and the opportunity to build and nurture a single product over the long term.</p>
<p>However, after a few months in which things did not go as I expected, I had to lower my expectations. That included having to consider the possibility of working for a consulting firm.</p>
<p>In fact, I applied for the offer that SNGULAR published on LinkedIn, where they were looking for a backend developer specializing in Ruby to work exclusively for a client in the fintech industry, fully remote.</p>
<h2>Selection process</h2>
<p>The next day I received a phone call from a person from human resources (HR). We talked for a few minutes to see if there could be a fit on both sides and finally scheduled a video call for a few days later.</p>
<p>During that interview, the HR person shared a presentation where they told me in great detail what the company is like and how things work, what was expected of me, and how I would work with the client. They did not reveal who the client was, though.</p>
<p>Then we moved on to the most common part of any interview, where we went into more detail about what we had already discussed briefly on the phone, especially my work experience and my professional and financial expectations. We also spoke in English for a while to test my level.</p>
<p>In the next step, I appreciated that the technical interview felt more like a friendly conversation than a test, which helped ease my nerves and allowed for a more natural chat about my skills. This step was lead by another SNGULAR developer who works for the same client where I would work and who also has a manager role for the rest of the developers working for this client.</p>
<p>He finally told me who the client was, <a href="/the-one-about-how-things-work-in-an-american-fintech">how things work with them</a>, and asked me about my experience with this or that technology. The usual.</p>
<p>There was a third step that they considered unnecessary, which was an interview with the head of SNGULAR and liaison with the client.</p>
<p>A few days later I received a call from HR people and they confirmed that they wanted to hire me if I was interested, but they wanted to wait and see how the interview with the client went.</p>
<p>I had that interview with the client a few days later. It was very similar to the technical interview I had had with the people from SNGULAR previously. It was mainly a conversation in English with the engineering manager and product manager of the team I would be part of if I ended up joining them.</p>
<p>They told me how the American fintech works, they asked me questions about my previous experience and how I had worked on the different previous projects, from a quite technical point of view.</p>
<p>SNGULAR told me afterwards that I would soon hear back about the client's feedback and that, if all went well, they would make me a formal offer. That offer arrived two days later.</p>
<p>The entire process was very straightforward and pleasant, and I felt very comfortable throughout. I am especially grateful that if any of the steps took longer than expected, HR people always kept me informed.</p>
<h2>Onboarding</h2>
<p>Before my first day at SNGULAR, I received some things at home: a laptop (which you can choose between Windows or macOS), a monitor, a keyboard, a mouse, a backpack, etc.</p>
<p>The first day begins with a meeting with the HR people to welcome you. They share a new presentation where they tell you where to find everything you will need and what to do in case you need help.</p>
<p>Of course, you have to sign the employment contract and configure the laptop following the instructions you receive by email.</p>
<p>Normally, after a couple of weeks of onboarding, you start working with the client. In my case, I already had a vacation scheduled, so my start with the client was delayed until I was back. In the meantime, I started a <a href="/the-one-about-learning-ruby">Ruby training</a> to some colleagues who were not assigned to a project at that time.</p>
<h2>Relationship with the company</h2>
<p>On a daily basis, I do not have any type of contact with anyone from SNGULAR, except for a teammate.</p>
<p>Weekly we have optional half-hour meetings where all the SNGULAR colleagues who work in the American fintech get together with the company's liaison with the client to share news from either side.</p>
<p>I also have a 1-to-1 once a month with the manager of the SNGULAR developers.</p>
<p>At the end of the year you have your own performance review and that of your managers.</p>
<p>During this time, I have had a meeting with the HR people when I completed 3 months in the company and another one when I celebrated the first year to see how things were going for me and to know how integrated I felt in the company. In both cases the answer was the same. As I work full-time with the client, my relationship with SNGULAR is mostly reduced to receiving my paycheck at the end of the month.</p>
<p>As a curiosity, they also send us a weekly survey to find out how our week has gone.</p>
<p>The company has an office in Madrid, which is quite far from home, more than 1 hour by metro or bus each way. So it is not an option for me to go there regularly.</p>
<p>On the other hand, everyone I have spoken to when I have requested help of any kind has always been very kind, from HR to IT, for example.</p>
<p>Every year, the company celebrates a Christmas and summer party in various parts of Spain and the rest of the world. Those people who do not live in a city where the party is held have the option of going to the party closest to their city and requesting a travel and accommodation allowance of up to €200.</p>
<p>In addition, this month we celebrated the company's 10th anniversary party in Madrid, which was attended by more than 600 people from all over the world. It was a great opportunity to meet most of my colleagues in person.</p>
<h2>Advantages</h2>
<ul>
<li><p>I work full-time for the same client.</p>
</li>
<li><p>In case of <a href="/the-one-about-layoffs-in-an-american-fintech">layoffs at the client</a>, you do not lose your job directly, but are reassigned to another project.</p>
<ul>
<li>If things do not work out in the assigned project on your part either, you can be reassigned to another project as well.</li>
</ul>
</li>
<li><p>You have an annual training budget of €500, which can be spent on courses, books, or tickets for technology events; and an annual well-being budget of €200, which can be spent on gym memberships or psychology sessions, to give a few examples.</p>
<ul>
<li><p>Although it is true that there is not much flexibility to spend it. For example, you cannot use it to buy a standing desk.</p>
</li>
<li><p>On the other hand, if the company considers that a course could be interesting for your work, they pay the corresponding license without deducting it from your annual budget. For instance, some of us got a license for the <a href="https://www.epicreact.dev/">Epic React</a> course by Kent C. Dodds.</p>
</li>
</ul>
</li>
<li><p>We have certain tax advantages for contracting a private health insurance policy. There are other benefits too that I do not enjoy, such as meal or transportation vouchers, because I do not go to the client's offices on a daily basis.</p>
</li>
<li><p>We have free access to AI tools, such as the Gemini pro model integrated into many of the company's tools.</p>
</li>
<li><p>We have the possibility of attending 1-hour English classes once a week, but outside of working hours.</p>
</li>
</ul>
<h2>Disadvantages</h2>
<ul>
<li><p>In my experience, you earn significantly less than in product development companies, such as start-ups or scale-ups.</p>
</li>
<li><p>Salary reviews are only conducted once a year, in February. If you have not been working at the company for at least 9 months at that time, usually you have to wait another year for a salary review.</p>
<ul>
<li><p>As I was told, in very exceptional cases of excellent performance you can opt for a salary review sooner.</p>
</li>
<li><p>In my case, my manager promised me a salary review next year and that they would also take into account that I did not have one this year.</p>
</li>
</ul>
</li>
<li><p>A lot of bureaucracy for everything, which can sometimes mean a simple request takes a few extra steps and days to approve, especially when it comes to an expense related to the money you are assigned annually for training or for well-being.</p>
</li>
<li><p>I have to report the hours I work twice: once in SNGULAR, where it is simpler because everything is charged to the same client, and another time at the client, where I have to specify the time dedicated to the different team initiatives.</p>
</li>
<li><p>By working full-time with the client, the feeling of belonging to the company is practically non-existing.</p>
</li>
</ul>
<h2>Conclusion</h2>
<p>Probably, many of the advantages and disadvantages mentioned above, if not all, are inherent to most consulting firms.</p>
<p>Taking everything into account, I think my experience so far is positive, although there are things that I would undoubtedly change.</p>
<p>Ultimately, my experience shows that the line between <em>consultancy</em> and <em>product company</em> can sometimes be blurry. I would say the most important question is the nature of your specific assignment and your day-to-day team.</p>
<p>Here I just wanted to talk honestly about what my experience with SNGULAR has been so far instead of just leaving a message on Glassdoor.</p>
<p>Looking forward to my second year in the company.</p>
<p>Thank you for reading and see you in the next one!</p>
]]></content:encoded></item><item><title><![CDATA[The one about accepting Tab suggestions in Cursor]]></title><description><![CDATA[In the previous post, I mentioned that I was starting to use Cursor as my main IDE, after having worked with VSCode and Copilot for a while.
A key advantage of Cursor for VSCode users is its support f]]></description><link>https://davidmontesdeoca.dev/the-one-about-accepting-tab-suggestions-in-cursor</link><guid isPermaLink="true">https://davidmontesdeoca.dev/the-one-about-accepting-tab-suggestions-in-cursor</guid><category><![CDATA[cursor IDE]]></category><category><![CDATA[cursor ai]]></category><category><![CDATA[cursor]]></category><category><![CDATA[AI]]></category><category><![CDATA[VS Code]]></category><category><![CDATA[macOS]]></category><dc:creator><![CDATA[David Montesdeoca]]></dc:creator><pubDate>Mon, 25 Aug 2025 18:29:19 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1756143334985/5b51dc25-e807-44b8-93ad-38116dff3f2d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In the <a href="/the-one-about-shortcuts-in-cursor-with-karabiner-elements">previous post</a>, I mentioned that I was starting to use Cursor as my main IDE, after having worked with VSCode and Copilot for a while.</p>
<p>A key advantage of Cursor for VSCode users is its support for the same keyboard shortcuts, allowing for a seamless transition:</p>
<img src="https://github.com/user-attachments/assets/cf849e77-a2b8-47c8-8160-9d3c73d0d0d1" alt="Cursor's Quick Start" />

<p>It allowed me to be really productive with this IDE almost immediately. And I say almost, because I ran into some issues when trying to accept AI suggestions on macOS.</p>
<p>Cursor allows you to autocomplete with <a href="https://docs.cursor.com/en/tab/overview">Tab</a>, the model they have trained in-house. With Tab, you can autocomplete multiple lines and blocks of code and jump in and across files to the next autocomplete suggestion.</p>
<p>As an example, I am going to show what happens when I try to accept a suggestion by adding a simple <code>console.log</code> to a JS file:</p>
<img src="https://github.com/user-attachments/assets/effbc211-2d1a-4c66-8976-24fc468a5e50" alt="User cannot accept suggestions with tab key in Cursor" />

<p>You can see that I can only accept a suggestion if I place the cursor over the suggestion itself and click <em>Accept</em>.</p>
<p>I dealt with this issue for longer than I would like to admit before I finally had time to investigate the root cause.</p>
<p>When I was able to look into the problem, I started with a quick Google search, where I did not find a <a href="https://www.reddit.com/r/cursor/comments/1juzs0d/does_anyone_elses_cursor_tab_autocomplete_get_in/">real solution</a>. Actually, that was a good sign, because it probably meant it was due to a misconfiguration on my end.</p>
<p>So, I continued by visually searching for the keyboard shortcuts associated with the <code>tab</code> key.</p>
<p>To do this, you have to go to the command palette (<code>control + shift + p</code>) and type <em>keyboard shortcuts</em>. There, you choose the option that does not specify <em>JSON</em>.</p>
<p>Then, you have to type <code>"tab"</code>, with the quotes, or on the right side of the search bar, click <code>Record Keys</code> and press the <code>tab</code> key.</p>
<img src="https://github.com/user-attachments/assets/3841c8e5-3a5d-4417-8239-c1c44066f1d0" alt="Cursor's Keyboard Shortcuts" />

<p>The first detail you might note is that the source for almost all the commands is <em>User</em>. This is because I do not like working with macOS, but I have to for work, so I configured the system to be as similar as possible to my personal laptop with Linux. I have a <a href="/the-one-about-working-with-macos-being-a-linux-user#heading-vscode">post</a> where I discussed this topic in more detail.</p>
<p>Another important detail to note is the first command associated with this key and its <a href="https://code.visualstudio.com/api/references/when-clause-contexts">when clause context</a>: <code>cpp.shouldAcceptTab</code>. We will need to use it pretty soon.</p>
<p>Next, we go back to the command palette and type <em>keyboard shortcuts</em> again. This time, we choose the option that specifies <em>JSON</em>.</p>
<p>Here we have to search for the commands associated with the <code>tab</code> key and disable them one by one until we find the one that is interfering with the expected behavior in Cursor. I started with the most obvious choice:</p>
<pre><code class="language-json">{
  "key": "tab",
  "command": "tab",
  "when": "editorTextFocus &amp;&amp; !editorReadonly &amp;&amp; !editorTabMovesFocus"
}
</code></pre>
<p>To test my theory, I commented out the entire entry for that shortcut:</p>
<img src="https://github.com/user-attachments/assets/57fe0a4a-2984-401a-9c46-5bad9de061c5" alt="Shortcut disabled in Cursor to validate default behavior" />

<p>As expected, with that command disabled, I can now accept Tab's suggestions. However, I noticed an strange behavior when indenting code, for example.</p>
<img src="https://github.com/user-attachments/assets/4d45787b-fd8a-47c7-a1a8-8672fc767d3c" alt="Weird behavior found while trying to indent code with tab key in Cursor" />

<p>So, I added the condition we saw earlier, negated, to this command's <code>when</code> clause:</p>
<pre><code class="language-json">{
  "key": "tab",
  "command": "tab",
  "when": "!cpp.shouldAcceptTab &amp;&amp; editorTextFocus &amp;&amp; !editorReadonly &amp;&amp; !editorTabMovesFocus"
}
</code></pre>
<p>Now I can accept Tab's suggestions without interfering with the default behavior of the <code>tab</code> key.</p>
<img src="https://github.com/user-attachments/assets/4361b086-3dfd-4c45-9db4-d21ab10992ee" alt="Weird behavior found regarding indentation in the code with tab key in Cursor is now solved" />

<p>Once again, there was one small detail that still was not working as I expected:</p>
<img src="https://github.com/user-attachments/assets/3dccdbf5-968d-4cdd-b372-49e56cba50bc" alt="User not always can accept suggestions with tab key in Cursor" />

<p>When I had a Tab suggestion and other suggestions from the current context, pressing the <code>tab</code> key accepted the first suggestion from the context, and only when I pressed the <code>tab</code> key again did it accept the expected suggestion.</p>
<p>To find the command that was interfering with this behavior, I disabled them one by one until I found the one:</p>
<pre><code class="language-json">{
  "key": "tab",
  "command": "acceptSelectedSuggestion",
  "when": "suggestWidgetHasFocusedSuggestion &amp;&amp; suggestWidgetVisible &amp;&amp; textInputFocus"
}
</code></pre>
<blockquote>
<p>Remember to re-enable each command after you have confirmed it is not the one you were looking for.</p>
</blockquote>
<p>I added the same negated condition to this <code>when</code> clause:</p>
<pre><code class="language-json">{
  "key": "tab",
  "command": "acceptSelectedSuggestion",
  "when": "!cpp.shouldAcceptTab &amp;&amp; suggestWidgetHasFocusedSuggestion &amp;&amp; suggestWidgetVisible &amp;&amp; textInputFocus"
}
</code></pre>
<p>The result is now exactly what I expected:</p>
<img src="https://github.com/user-attachments/assets/54167738-de7b-476a-b667-9b34010a2a3b" alt="User can accept suggestions with tab key in Cursor" />

<p>In this case, the standard behavior of the <code>tab</code> key was clashing with Cursor's AI suggestion feature. The solution was to only trigger the default <em>tab</em> and <em>acceptSelectedSuggestion</em> commands when the AI does not have a suggestion ready.</p>
<p>The key takeaway is not just about this specific fix, but about the power of <code>when</code> clauses for fine-tuning the IDE to behave exactly how you want it to. Hopefully, this guide saves you some time and helps you get back to coding faster.</p>
<p>Thank you for reading and see you in the next one!</p>
]]></content:encoded></item><item><title><![CDATA[The one about shortcuts in Cursor with Karabiner-Elements]]></title><description><![CDATA[Recently, I was assigned a spike to check the feasibility of migrating a React application to a microfrontend with Sinatra + htmx entirely using AI. In this case, with Claude Code.
Of course, I did no]]></description><link>https://davidmontesdeoca.dev/the-one-about-shortcuts-in-cursor-with-karabiner-elements</link><guid isPermaLink="true">https://davidmontesdeoca.dev/the-one-about-shortcuts-in-cursor-with-karabiner-elements</guid><category><![CDATA[AI]]></category><category><![CDATA[cursor IDE]]></category><category><![CDATA[cursor]]></category><category><![CDATA[VS Code]]></category><category><![CDATA[osascript]]></category><category><![CDATA[macOS]]></category><category><![CDATA[macOS Tips]]></category><category><![CDATA[copilot]]></category><category><![CDATA[karabiner-elements]]></category><dc:creator><![CDATA[David Montesdeoca]]></dc:creator><pubDate>Wed, 23 Jul 2025 13:43:17 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1753278053674/2552475e-82db-479e-b67f-abf5761fdd8f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Recently, I was assigned a spike to check the feasibility of migrating a React application to a microfrontend with <a href="https://sinatrarb.com/">Sinatra</a> + <a href="https://htmx.org/">htmx</a> entirely using AI. In this case, with <a href="https://www.anthropic.com/claude-code">Claude Code</a>.</p>
<p>Of course, I did not have to try to do the entire migration at once, but rather migrate a list of beneficiaries and a detail view of a specific beneficiary, following the existing design.</p>
<p>The main goal was to find out if it was possible to do it with AI and, if so, how long it would take to complete each functionality compared to how long I estimated it would take to do the same manually.</p>
<p>The client I work for gives each developer a <a href="https://github.com/features/copilot">Copilot</a> license for VSCode or a license for <a href="https://cursor.com/">Cursor</a>. You can switch between them as many times as you want, but whenever you activate one, the other automatically expires.</p>
<p>In my case, I have been working with VSCode for a while, but I was offered the possibility of trying Cursor as well, to test the integration with Claude Code.</p>
<p>Cursor is an AI-native IDE forked from VS Code, so it allows for a very easy transition from one IDE to the other. However, I found that a few basic shortcuts did not work as expected: save, find, copy, paste, undo, etc.</p>
<p>The issue was directly related to the shortcuts I configured with <a href="https://karabiner-elements.pqrs.org/">Karabiner-Elements</a> in macOS to behave like in Linux. I talked about it some time ago <a href="/the-one-about-working-with-macos-being-a-linux-user#heading-karabiner-elements">here</a>.</p>
<p>VSCode's <em>bundler identifier</em> is very easy to find anywhere if you look for it:</p>
<pre><code class="language-plaintext">com.microsoft.VSCode
</code></pre>
<p>Cursor's bundler identifier is not as widely known. Finally, I came across <a href="https://github.com/cursor/cursor/issues/901">this Cursor issue</a>, which shows how to get it:</p>
<pre><code class="language-bash">osascript -e 'id of app "Cursor"'
</code></pre>
<p>The surprise was that the value seems quite random:</p>
<pre><code class="language-plaintext">com.todesktop.230313mzl4w4u92
</code></pre>
<p>I did not know <a href="https://victorscholz.medium.com/what-is-osascript-e48f11b8dec6">osascript</a>, so I thought it might not be the bundler identifier I was looking for.</p>
<p>To be sure, I did the same with VSCode:</p>
<pre><code class="language-bash">osascript -e 'id of app "code"'
</code></pre>
<p>The value is as expected:</p>
<pre><code class="language-plaintext">com.microsoft.VSCode
</code></pre>
<p>I assumed that, at least for now, that Cursor's bundle identifier would be enough. I added that value to the array of bundle identifiers of one of the shortcuts configured for VSCode in Karabiner-Elements and it worked perfectly.</p>
<p>Finally, I added it to the rest of the shortcuts for VSCode. You can see the complete configuration in <a href="https://gist.github.com/backpackerhh/2448998967f178f0114de6c6a3eb37df">this gist</a>.</p>
<p>In case you did not know, the <a href="https://karabiner-elements.pqrs.org/docs/json/complex-modifications-manipulator-definition/conditions/frontmost-application/#investigate-the-bundle-identifier-and-file-path">official Karabiner-Elements documentation</a> includes an alternative way to get an application's bundle identifier.</p>
<p>In any case, I continued investigating about that unusual bundler identifier and found <a href="https://forum.cursor.com/t/cursor-bundle-identifier/779">this thread</a> in the Cursor forum, where Cursor's CEO and founder confirm that this value will not change, because the application is already published.</p>
<p>Therefore, we can rest assured that it will not be necessary to change the configuration in the future.</p>
<p>Thank you for reading and see you in the next one!</p>
]]></content:encoded></item><item><title><![CDATA[The one with issues scheduling Sidekiq jobs in production]]></title><description><![CDATA[In the project I am currently working on, we have faced some issues lately scheduling Sidekiq jobs in one of our applications.
In any case, I must admit that these issues were caused both by bad decis]]></description><link>https://davidmontesdeoca.dev/the-one-with-issues-scheduling-sidekiq-jobs-in-production</link><guid isPermaLink="true">https://davidmontesdeoca.dev/the-one-with-issues-scheduling-sidekiq-jobs-in-production</guid><category><![CDATA[sidekiq-scheduler]]></category><category><![CDATA[software development]]></category><category><![CDATA[sidekiq]]></category><category><![CDATA[Ruby]]></category><category><![CDATA[sinatrarb]]></category><category><![CDATA[Redis]]></category><category><![CDATA[observability]]></category><category><![CDATA[o11y]]></category><category><![CDATA[Zeitwerk]]></category><category><![CDATA[RTFM]]></category><dc:creator><![CDATA[David Montesdeoca]]></dc:creator><pubDate>Sun, 29 Jun 2025 08:36:20 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1751186071676/7fba24fa-a92e-443e-8c4e-df8e2bea110f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In the project I am currently working on, we have faced some issues lately scheduling <a href="https://github.com/sidekiq/sidekiq">Sidekiq</a> jobs in one of our applications.</p>
<p>In any case, I must admit that these issues were caused both by bad decisions made in the past and by not having configured these tools properly more recently.</p>
<blockquote>
<p>I will use the <a href="https://github.com/sidekiq/sidekiq/wiki/Best-Practices#4-use-precise-terminology">precise terminology</a> that the Sidekiq wiki recommends. Therefore, I will not use the term <em>worker</em>.</p>
</blockquote>
<h2>Context</h2>
<p>We started with a legacy payouts application, what we call <strong>payouts v1</strong> (just <strong>v1</strong> from now on).</p>
<p>It is a somewhat old Sinatra application that, among other things, does not use <a href="https://github.com/fxn/zeitwerk">Zeitwerk</a>, so:</p>
<ul>
<li><p>It does not follow a conventional file structure. The namespace of the different classes and modules does not respect the directory hierarchy, so it is common not to find what you expect where you would expect to find it.</p>
</li>
<li><p>There is no configuration for <a href="https://github.com/fxn/zeitwerk#inflection">inflection</a>, but submodules like AMQP or SFTP exist anyway.</p>
</li>
<li><p>It does not use <em>eager loading</em> in much of the code, so you must require classes and modules explicitly at the beginning of each file.</p>
</li>
</ul>
<p>For a new feature of internal payouts between the company's own accounts, it was decided to do it separately from v1.</p>
<p>The team considered the possibility of making what we call <strong>payouts v2</strong> (just <strong>v2</strong> from now on) as an independent (micro)service that would communicate with v1 through domain events if needed, but the final choice was leaving the source code of v2 in the same repository as v1.</p>
<p>However, it does not follow the classic directory structure of other <strong>monorepos</strong> I have worked on before, where each application has a separate subdirectory. In this case, v1 code is still at the root of the repository, while v2 code is inside a <code>/v2</code> directory.</p>
<p>Although v2 is a somewhat more modern application, using Zeitwerk, for example, there is also a lot of logic copied from v1 and adapted as needed. Sidekiq configuration was not copied, though.</p>
<p>In v1, all available queues, their weight, the maximum concurrency, and the queue where each job will run in are explicitly defined:</p>
<pre><code class="language-bash">bundle exec sidekiq -r ./config/sidekiq_boot.rb -q high_priority,6 -q medium_priority,3 -q low_priority,1 —concurrency 10
</code></pre>
<p>In v2, nothing is explicitly defined, so by default, all jobs run in the <a href="https://github.com/sidekiq/sidekiq/wiki/Advanced-Options#queues">default queue</a> with a <a href="https://github.com/sidekiq/sidekiq/wiki/Advanced-Options#concurrency">concurrency with 5 threads</a>:</p>
<pre><code class="language-bash">bundle exec sidekiq -r ./config/sidekiq_boot.rb
</code></pre>
<p>Both applications run in separate Docker containers.</p>
<p>Although we also have separate containers to run the background jobs with Sidekiq separately, <strong>both applications share the same Redis instance</strong>. Not only between them, but they also share it with other main applications of the project.</p>
<blockquote>
<p>The team is expected to start working on using a non-shared Redis for the payouts application very soon.</p>
</blockquote>
<p>The current configuration has caused us many problems, especially with Sidekiq, although we have faced other problems that we will not discuss here.</p>
<p>In the case of v1, we use the <a href="https://github.com/sidekiq-scheduler/sidekiq-scheduler">sidekiq-scheduler</a> extension to schedule some Sidekiq jobs:</p>
<pre><code class="language-ruby"># config/initializers/sidekiq.rb
require "sidekiq"
require "sidekiq-scheduler"

# other logic omitted

redis_config = {
  namespace: "sidekiq_payouts",

  # other keys omitted
}

Sidekiq.configure_client do |config|
  config.redis = redis_config

  # other configurations omitted
end

Sidekiq.configure_server do |config|
  config.redis = redis_config

  # other configurations omitted
end

Sidekiq::Scheduler.enabled = true
Sidekiq::Scheduler.dynamic = true
</code></pre>
<pre><code class="language-ruby"># config/sidekiq_boot.rb
require "sidekiq"
require "sidekiq-scheduler"

require_relative "./boot"

# other logic omitted

Sidekiq.set_schedule(
  "bank_acknowledge_notification_job",
  {
    every: "3 minutes",
    class: "Jobs::BankAcknowledgedNotificationJob",
    queue: "high_priority"
  }
)

SidekiqScheduler::Scheduler.instance.load_schedule!
</code></pre>
<h2>Part I</h2>
<p>Recently, a new functionality require us to schedule jobs in v2 as well, and we used the same extension for that purpose.</p>
<p>We started by copying the scheduling configuration, not from v1 but from other more modern project, where the same Redis instance is not shared.</p>
<p>An important detail is that when Sidekiq was configured in v2, the same Redis configuration was kept, including the namespace, for the server and the client:</p>
<pre><code class="language-ruby"># v2/config/initializers/sidekiq.rb
require "sidekiq"

redis_config = {
  namespace: "sidekiq_payouts",

  # other keys omitted
}

Sidekiq.configure_client do |config|
  config.redis = redis_config

  # other configurations omitted
end

Sidekiq.configure_server do |config|
  config.redis = redis_config

  # other configurations omitted
end
</code></pre>
<p>In the first approach to schedule jobs in v2, the configuration was something like this:</p>
<pre><code class="language-ruby"># v2/config/sidekiq_boot.rb
require "sidekiq"
require "sidekiq-scheduler"

require_relative "./boot"

# other logic omitted

Sidekiq.schedule = {
  "bank_transfers_download_feedback_files_job": {
    cron: "0 */5 * * * *",
    class: "Jobs::BankTransfers::DownloadFeedbackFilesJob"
  }
}.freeze

Sidekiq::Scheduler.enabled = true
</code></pre>
<p>This job is responsible for downloading feedback files from the banks indicating whether the bank transfers were successful or not.</p>
<p>We deployed the changes to production and checked that <strong>the new job was running correctly every 5 minutes</strong>.</p>
<blockquote>
<p>These changes were part of an epic that was still in progress at that moment, so our clients were not yet using them in production.</p>
</blockquote>
<p>Coincidentally, the team members involved in this feature had the next day off because it was a public holiday in Madrid.</p>
<p>During our day off, a support ticket was opened reporting that, for some payments from a certain bank, the receipt confirmation had not been sent.</p>
<p>The next day, when we returned to work, we quickly identified the problem: The bank saves the feedback files on the same SFTP server and in the same directory for both processes, with slightly different filename patterns.</p>
<p>The process in v2 was saving a backup copy of those files in an S3 bucket and deleting the files that v1 was supposed to process from the SFTP server.</p>
<p><strong>The process in v1 runs every 3 minutes</strong>, so in a particular case, the process in v2 got ahead of the process in v1. The same would have happened the other way around if the new feature for v2 would have been used in production.</p>
<p>Both processes use a <em>filename pattern</em> to determine if they should process a file.</p>
<p>e.g. <code>dbdi.000_report_00000000000000000.xml</code>:</p>
<pre><code class="language-ruby"># v1 filename pattern
/dbdi.000_report_*/
</code></pre>
<pre><code class="language-ruby"># v2 filename pattern
/dbdi\.\d{3}_report_\d{17}\.xml\z/
</code></pre>
<blockquote>
<p>The filename pattern in v2 is more restrictive but also matches the given filename.</p>
</blockquote>
<p>During the QA process of the new feature in v2, we did not notice this problem because it never happened that the process in v1 got ahead of the process in v2.</p>
<p>The solution was quite clear: change the filename pattern in v2, as it includes a different prefix than v1.</p>
<p>However, we first quickly deployed a hotfix to production to prevent the same thing from happening again and thus be able to take enough time to test the change calmly:</p>
<pre><code class="language-ruby"># v2/config/sidekiq_boot.rb
require "sidekiq"
require "sidekiq-scheduler"

require_relative "./boot"

# other logic omitted

# Sidekiq.schedule = {
#   "bank_transfers_download_feedback_files_job": {
#      cron: "0 */5 * * * *",
#      class: "Jobs::BankTransfers::DownloadFeedbackFilesJob"
#   }
# }.freeze

# Sidekiq::Scheduler.enabled = true
</code></pre>
<blockquote>
<p>A better approach is to use environment variables for enabling/disabling that functionality, so we would not need a new deployment.</p>
</blockquote>
<p>In production, we simply checked that the new job from v2 was no longer running every 5 minutes. Then, we proceeded to apply the change in the filename pattern and re-enabled Sidekiq Scheduler.</p>
<pre><code class="language-ruby"># v1 filename pattern
/\Adbdi.000_report_*/
</code></pre>
<pre><code class="language-ruby"># v2 filename pattern
/\A[A-Z]{3}_dbdi\.\d{3}_report_\d{17}\.xml\z/
</code></pre>
<p>We pushed the changes, the CI pipeline passed, and we calmly tested in our test environment that the new job from v2 did not process the files for v1.</p>
<p>Approximately 4 hours after having deployed the hotfix, we deployed the new changes to production.</p>
<p>It was then that we started monitoring the scheduled jobs in both v1 and v2.</p>
<p>There we realized that <strong>since the deployment of the hotfix, no scheduled job from v1 had been processed again</strong>.</p>
<p>I'll tell you the reason later in <a href="#part-ii">part II</a>. No spoilers here!</p>
<p>We ran a Rake task in our test environment that shows us the output of <code>Sidekiq.get_schedule</code> in v1:</p>
<pre><code class="language-json">{
  "bank_transfers_download_feedback_files_job": {
     "cron": "0 */5 * * * *",
     "class": "Jobs::BankTransfers::DownloadFeedbackFilesJob",
     "queue": "default"
  }
}
</code></pre>
<p>There was no doubt. <strong>The scheduler from v2 was overwriting the scheduler from v1</strong>.</p>
<pre><code class="language-mermaid">graph TD;
    subgraph "Sidekiq Containers"
        V1[Payouts v1 Container];
        V2[Payouts v2 Container];
    end

    subgraph Redis[Shared Redis Instance]
        SharedNamespace("sidekiq_payouts" namespace);
    end

    V1 -- "Writes schedule to" --&gt; SharedNamespace;
    V2 -- "OVERWRITES schedule in" --&gt; SharedNamespace;

    style V2 fill:#f7baba,stroke:#c71f1f,stroke-width:2px
</code></pre>
<p>We thought then that we could schedule the job from v2 in v1, since we knew that the configuration for v1 had been working well until then:</p>
<pre><code class="language-ruby"># config/sidekiq_boot.rb

require "sidekiq"
require "sidekiq-scheduler"

require_relative "./boot"

# other logic omitted

Sidekiq.set_schedule(
  "bank_acknowledge_notification_job",
  {
    every: "3 minutes",
    class: "Jobs::BankAcknowledgedNotificationJob",
    queue: "high_priority"
  }
)

Sidekiq.set_schedule(
  "bank_transfers_download_feedback_files_job",
  {
    every: "5 minutes",
    class: "Jobs::BankTransfers::DownloadFeedbackFilesJob"
  }
)

SidekiqScheduler::Scheduler.instance.load_schedule!
</code></pre>
<p><strong>Desperate times call for desperate measures.</strong></p>
<p>We deleted all the configuration for Sidekiq Scheduler from v2 and tested that everything worked.</p>
<p>Note that I said <em>the configuration</em>. This is an important detail. More on that later.</p>
<p>To give some more context, when we merge the changes of an <a href="https://docs.gitlab.com/user/project/merge_requests/">MR</a>, they are first deployed to the staging environment. There we were able to check that everything was working as expected by scheduling all the jobs, both for v1 and v2.</p>
<p>We deployed the changes to production and requested in a support ticket to run the same Rake task that before, the one that shows the scheduled jobs.</p>
<p>We confirmed that the output of that task was showing all the scheduled jobs we expected.</p>
<p>That day we finished around 6 p.m. Not bad for a Friday afternoon.</p>
<p>As you may have imagined, this story does not end here.</p>
<h2>Part II</h2>
<p>For 10 days, we forgot about the scheduled Sidekiq jobs. There had been 6 deployments to production since that fateful Friday and everything had been working as expected. We had been lucky until then.</p>
<p>With the 7th deployment, which had nothing to do with Sidekiq jobs, our luck ran out.</p>
<p>The day after that deployment, we realized that none of the scheduled jobs had been processed since then.</p>
<p>We first checked what had been deployed to production since the last time we fixed it. Again, nothing related to Sidekiq jobs.</p>
<p>We found the following trace in our logs in production:</p>
<blockquote>
<p>Removing schedule bank_acknowledge_notification</p>
<p>Removing schedule bank_transfers_download_feedback_files_job</p>
</blockquote>
<p>We tried restarting the application in production without success. Same trace in the logs.</p>
<p>We had to deploy some changes to production that same morning, so we decided to wait and see if we got lucky again, although we were still trying to figure out the root of the problem. Again, no luck.</p>
<p>That afternoon, the team dedicated the refinement meeting we had scheduled to figuring out what was going on.</p>
<p>Although we had removed the configuration for Sidekiq Scheduler from the Sidekiq container from v2, we were still seeing the <a href="https://github.com/sidekiq-scheduler/sidekiq-scheduler/blob/master/lib/sidekiq-scheduler/scheduler.rb#L71">following trace</a> in the logs:</p>
<blockquote>
<p>Scheduling Info</p>
</blockquote>
<p>We started the application locally and carefully checked the logs, filtering for references to "scheduler".</p>
<p>Finally, we found <a href="https://github.com/getsentry/sentry-ruby/blob/master/sentry-sidekiq/lib/sentry/sidekiq-scheduler/scheduler.rb#L5">the problem</a> in one of our dependencies:</p>
<pre><code class="language-ruby">begin
  require "sidekiq-scheduler"
rescue LoadError
  return
end
</code></pre>
<p>We had deleted the configuration from v2, but <strong>we had not removed the gem from the Gemfile in v2</strong>.</p>
<p>We deployed that change to production and checked that everything was working correctly.</p>
<p><strong>We had found a race condition</strong>. We had been lucky from the beginning because the Sidekiq container from v1 had been starting after the Sidekiq container from v2.</p>
<p>The moment the Sidekiq container from v2 started after the Sidekiq container from v1, all scheduled jobs from v1 were removed, because v2 had no scheduled jobs.</p>
<p>Of course, we did not carefully read the <a href="https://github.com/sidekiq-scheduler/sidekiq-scheduler#notes-when-running-multiple-sidekiq-processors-on-the-same-redis">gem's documentation</a>, where we would have found the following recommendation:</p>
<blockquote>
<p>If you're running multiple Sidekiq processes on the same Redis namespace with different configurations, you'll want to explicitly disable Sidekiq Scheduler for the other processes not responsible for the schedule. If you don't, the last booted Sidekiq processes' schedule will be what is stored in Redis.</p>
</blockquote>
<p><a href="https://en.wikipedia.org/wiki/RTFM">RTFM!</a></p>
<p>And that is the reason why the scheduled jobs from v1 had stopped running when we had disabled Sidekiq Scheduler in v2 with the hotfix.</p>
<p>We also discovered that scheduling jobs from v2 in the Sidekiq container from v1 had worked by chance from the beginning.</p>
<p>The scheduled job from v2 did not have a explicitly defined queue, so it was being processed in the <em>default</em> queue. As that queue was not defined in the Sidekiq container from v1, by sharing the same Redis instance and the same namespace, that job had been processed in the Sidekiq container from v2.</p>
<h2>Part III</h2>
<p>During one of the tests that the stakeholders did in production of this new functionality, we received an alert in the corresponding Slack channel because an error had been registered during the process of downloading the feedback files from the bank.</p>
<p>We rushed to investigate it and found that the same file had been processed twice.</p>
<p>The first time the process had succeeded, so a record was created in the database with details of the downloaded feedback file, including the filename. The second time an error was registered, because we already had that record in the database for the same filename.</p>
<blockquote>
<p>For now, it is registered as an error, although we should probably change it to a warning later.</p>
</blockquote>
<p>Our first thought was that maybe the bank had mistakenly left us the same file on the SFTP server twice. However, that hypothesis did not add up because we only delete files from the SFTP server when the entire process succeeds, and the job is scheduled every 5 minutes, so we should have been registering the exact same error every 5 minutes.</p>
<p>Then we realized that <strong>the same file had been processed twice in a matter of 2 seconds</strong>.</p>
<p>Without finding an apparent reason for this to be happening, we assumed that the cause would be related to still having multiple Sidekiq containers sharing the same Redis namespace.</p>
<p>We already had a task on our board to explicitly add queues to jobs classes and scheduled jobs in v2, so we started from there.</p>
<p>The idea was to deploy it in two steps, doing the following:</p>
<ol>
<li>Define the <em>default</em> queue in v1, so that any job from v2 that was already enqueued at the time of deployment could be successfully processed in v1.</li>
</ol>
<pre><code class="language-bash">bundle exec sidekiq -r ./config/sidekiq_boot.rb -q default,1 -q high_priority,6 -q medium_priority,3 -q low_priority,1 —concurrency 10
</code></pre>
<p>And explicitly specify in every job class in v2 in which queue should be processed:</p>
<pre><code class="language-bash">bundle exec sidekiq -r ./config/sidekiq_boot.rb -C ./config/sidekiq.yml
</code></pre>
<p>In that configuration file, something like the following is specified:</p>
<pre><code class="language-yaml"># v2/config/sidekiq.yml
:concurrency: 10
:queues:
  - [v2_high_priority,6]
  - [v2_medium_priority,3]
  - [v2_low_priority,1]
</code></pre>
<p>In the job class, it is defined as follows:</p>
<pre><code class="language-ruby"># v2/app/jobs/bank_transfers/download_feedback_files_jobs.rb
require "sidekiq"

module Jobs
  module BankTransfers
    class DownloadFeedbackFilesJob
      include Sidekiq::Job

      sidekiq_options retry: false, queue: "v2_medium_priority"

      # other logic omitted
    end
  end
end
</code></pre>
<p>Note that when a job is scheduled from v1, a queue from v1 is used, while when the same job is processed in a non-scheduled way, a queue from v2 is used:</p>
<pre><code class="language-ruby"># config/sidekiq_boot.rb

# other logic omitted

Sidekiq.set_schedule(
  "bank_transfers_download_feedback_files_job",
  {
    every: "5 minutes",
    class: "Jobs::BankTransfers::DownloadFeedbackFilesJob",
    queue: "medium_priority"
  }
)
</code></pre>
<ol>
<li>Delete the <em>default</em> queue from v1, now that all jobs run in a specific queue.</li>
</ol>
<p>However, with this configuration, we encountered unexpected errors at that time when processing the scheduled job from v2 in v1:</p>
<blockquote>
<p>NameError: Services::BankTransfers::DownloadBatchFiles::Dry</p>
</blockquote>
<p>In our services, we use <code>Dry::Monads</code> to return <code>Success</code> or <code>Failure</code>, so it should be enough to add the following:</p>
<pre><code class="language-yaml"># v2/app/services/bank_transfers/download_feedback_files.rb
require "dry-monads"
</code></pre>
<p>Indeed, that error was already resolved, but one error after another kept appearing, in what seemed like an endless loop. With each require we added, a new dependency had to be specifically required. Some tests in v1 started to fail due to this problem.</p>
<p>The problem basically is that the v2 files are not autoloaded in v1 because it is not really necessary. In practice, they are independent applications, although we were taking advantage of the fact that both are in the same repository to process jobs from v2 in v1.</p>
<p>A possible solution for the dependency issue is the following:</p>
<pre><code class="language-bash">bundle exec sidekiq -r ./config/sidekiq_boot.rb -r ./v2/config/sidekiq_boot.rb &lt;...rest omitted&gt;
</code></pre>
<p>Although that configuration did not solve the problem of the scheduled jobs running twice.</p>
<p>Finally, we opted for implementing another solution that the team had agreed on in another of the tasks that were on our board: <strong>Configure Sidekiq in payouts v1 and v2 to use different Redis namespaces</strong>.</p>
<p>We added sidekiq-scheduler to the Gemfile again in v2 and defined a different namespace for each application:</p>
<pre><code class="language-ruby"># v2/config/initializers/sidekiq.rb
require "sidekiq"

redis_config = {
  namespace: "v2_sidekiq_payouts",

  # other keys omitted
}

Sidekiq.configure_client do |config|
  config.redis = redis_config

  # other configurations omitted
end

Sidekiq.configure_server do |config|
  config.redis = redis_config

  # other configurations omitted
end
</code></pre>
<pre><code class="language-ruby"># v2/config/sidekiq_boot.rb
require "sidekiq"
require "sidekiq-scheduler"

require_relative "./boot"

# other logic omitted

Sidekiq.schedule = {
  "bank_transfers_download_feedback_files_job": {
    cron: "0 */5 * * * *",
    class: "Jobs::BankTransfers::DownloadFeedbackFilesJob",
    queue: "v2_medium_priority"
  }
}.freeze

Sidekiq::Scheduler.enabled = true
</code></pre>
<pre><code class="language-mermaid">graph TD;
    subgraph "Sidekiq Containers"
        V1[Payouts v1 Container];
        V2[Payouts v2 Container];
    end

    subgraph Redis[Shared Redis Instance]
        direction LR
        V1_NS("sidekiq_payouts" namespace);
        V2_NS("v2_sidekiq_payouts" namespace);
    end

    V1 -- "Reads/Writes" --&gt; V1_NS;
    V2 -- "Reads/Writes" --&gt; V2_NS;

    style V1_NS fill:#d4edda,stroke:#155724,stroke-width:2px
    style V2_NS fill:#d4edda,stroke:#155724,stroke-width:2px
</code></pre>
<p>We also checked that the namespace change only affected Sidekiq. For example, the gem developed by the company to define feature flags uses Redis internally, but uses its own namespace.</p>
<p>We deployed these changes in our test environment and were able to verify that everything worked as expected. We deployed to staging and production and we got the same result.</p>
<p>With this configuration, each Sidekiq container is independent at the namespace level, although they still share Redis between them and with other applications.</p>
<p>We also managed to get the scheduled jobs to run only once.</p>
<h2>Conclusion</h2>
<p>We definitely learned the hard way how to configure Sidekiq properly according to our current needs.</p>
<p>It was not easy, it took us a lot of time, but we are happy with the result.</p>
<p>Some lessons learned:</p>
<ul>
<li><p>Better organize the structure of applications that share the same repository.</p>
</li>
<li><p>Carefully read the documentation of the tools we use.</p>
</li>
<li><p>Add alerts when a certain period passes without processing scheduled jobs.</p>
</li>
<li><p>Test any change more exhaustively before uploading it to production.</p>
</li>
<li><p>Do not share a Redis instance between multiple applications, especially the same namespace, because it can lead to unpredictable behavior.</p>
</li>
</ul>
<p>Thank you for reading, and see you in the next one!</p>
]]></content:encoded></item><item><title><![CDATA[The one about unrealistic expectations on software engineers]]></title><description><![CDATA[This is a topic I have discussed countless times with engineer peers and friends outside the tech world alike.
It is generally agreed that software engineers occupy a privileged position. This profess]]></description><link>https://davidmontesdeoca.dev/the-one-about-unrealistic-expectations-on-software-developers</link><guid isPermaLink="true">https://davidmontesdeoca.dev/the-one-about-unrealistic-expectations-on-software-developers</guid><category><![CDATA[software development]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[technology]]></category><category><![CDATA[tech ]]></category><category><![CDATA[job search]]></category><dc:creator><![CDATA[David Montesdeoca]]></dc:creator><pubDate>Wed, 28 May 2025 19:26:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1748369490093/dd8f716c-0002-403b-ba5f-3fd1821f9d45.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>This is a topic I have discussed countless times with engineer peers and friends outside the tech world alike.</p>
<p>It is generally agreed that software engineers occupy a privileged position. This profession is not only highly in-demand but also offers a host of advantages rarely found elsewhere: competitive salaries, <a href="/the-one-about-my-experience-working-remotely">remote work</a>, and flexible schedules, among others.</p>
<p>However, this privilege comes with a set of <strong>unrealistic expectations</strong>. A case of <em>divine justice</em>, you might say?</p>
<p>I recently came across an excellent post about <a href="https://0x1.pt/2025/04/06/the-insanity-of-being-a-software-engineer/">the insanity of being a software engineer</a> and felt compelled to expand on its insights.</p>
<p><strong>Getting started is not easy at all</strong>, especially if you have no experience. I would say that it is difficult to find job offers for junior engineers, but in those rare cases where you do find them, it is common that companies ask for 2-3 years of experience. How can one get the experience if no one will provide the first opportunity?</p>
<p>Nowadays, you have bootcamps, for example, that have collaboration agreements with tech companies that give the student a chance to start an internship upon finishing, but they are not guaranteed to continue in the company once the internship is over.</p>
<p>That brings me to another key point. While it is probably nothing new, I feel that the <strong>hiring processes nowadays are wild</strong>.</p>
<p>Companies have <a href="https://mrshiny608.github.io/MrShiny608/commentary/2025/04/29/AdversarialInterviews.html#unrealistic-expectations-">unrealistic job requirements</a>, often <strong>demanding an entire development team's worth of skills in a single candidate</strong>. It risks discouraging candidates who would otherwise be excellent fits but feel they do not tick enough boxes to apply.</p>
<p>But even when you decide to apply for a position, many candidates are rejected because <strong>interviewers fail to see that skills are transferable</strong> and the match should not be limited to a predefined checklist.</p>
<p>The way I see it, this situation is a consequence of the normalization that engineers must master (almost) all related areas. For instance, check the roadmaps for <a href="https://roadmap.sh/backend">backend</a>, <a href="https://roadmap.sh/frontend">frontend</a>, <a href="https://roadmap.sh/full-stack">full stack</a>, or <a href="https://roadmap.sh/software-architect">software architects</a>.</p>
<p>For a typical <em>backend</em> position, you are expected to master the main programming language used in the project, but preferably you should master more than one, because that will give you the tools to apply different approaches depending on the occasion, especially if those languages allow you to apply different programming paradigms, such as OOP and functional.</p>
<p>Of course, you must be fluent with the entire ecosystem surrounding those languages, starting with the most popular frameworks, because they are the tool you will work with in your day-to-day.</p>
<p>You are also expected to master databases, at least one of the most popular SQL DBMS (PostgreSQL, MySQL) and some NoSQL (MongoDB, Redis). Performance tuning, data modeling, security... What happened to the role of the <a href="https://www.oracle.com/database/what-is-a-dba/">database administrator (DBA)</a>?</p>
<p>Experience designing, implementing and consuming REST APIs or GraphQL is also a must.</p>
<p>Depending on your level, you must also be able to design efficient systems, always with performance, scalability, maintainability, and security in mind.</p>
<p>In a world dominated by (micro)services, you are also expected to know every architectural pattern, such as <a href="https://aws.amazon.com/what-is/eda/">event-driven architecture (EDA)</a>.</p>
<p>You could think that is enough, right? Well, not quite...</p>
<p>You must know your way around the command line to be able to configure the system you are working on and ensure it is working nicely.</p>
<p>Do not forget about monitoring the system, responding to incidents caused by changes introduced to applications in your team's governance, and automating tasks to ensure the reliability of the system.</p>
<p>You are expected to be proficient with containers and cloud providers too, configuring and provisioning the infrastructure using code instead of manual processes and settings (<a href="https://aws.amazon.com/what-is/iac/">IaC</a>). Who needs <a href="https://roadmap.sh/r/system-engineer">SysAdmins</a>, <a href="https://roadmap.sh/devops">SREs, or DevOps</a> anymore, right?</p>
<p>Do not forget about <a href="https://roadmap.sh/ai-engineer">AI</a>, which makes it increasingly common to find job offers asking for knowledge in LLMs, RAG, fine-tuning, or transformers, to name a few.</p>
<p>And all of that is without even getting into the <em>full-stack</em> role, where you will need to add <em>frontend</em> skills to the equation.</p>
<p>On the one hand, if the main workload is on the <em>backend</em>, you will not need to be an expert, but you will certainly have to know JS and the web ecosystem well enough.</p>
<p>On the other hand, if the entire project stack is based on JS, also working with Node on the <em>backend</em>, for example, then you will have to be an expert in the entire web and JS ecosystem in particular, whether it is frameworks, UI libraries, or other tools.</p>
<p>For sure, in a more purely <em>frontend</em> role, you are expected to be an excellent designer, have good taste for interfaces, and have solid knowledge of UX and usability.</p>
<p>And we are still just getting started, because I have not yet mentioned all the expertise required no matter the role.</p>
<p>You are required a high level of technical proficiency and a strong understanding of software development best practices, partaking in all stages of the development lifecycle, from initial task definition to final deployment.</p>
<p>Needless to say, your code must be clean, scalable, maintainable, and testable, preferably applying <a href="https://martinfowler.com/bliki/TestDrivenDevelopment.html">TDD</a>. For that, you will need to master <a href="https://refactoring.guru/design-patterns">design patterns</a>, <a href="https://refactoring.guru/refactoring">refactoring techniques</a>, and every software design principle ever coined, such as <a href="https://en.wikipedia.org/wiki/SOLID">SOLID</a>.</p>
<p>I really like what the author that somehow inspired this post says about it:</p>
<blockquote>
<p>Software gets more complicated. All of this complexity is there for a reason. But what happened to specializing? When a house is being built, tons of people are involved: architects, civil engineers, plumbers, electricians, bricklayers, interior designers, roofers, surveyors, pavers, you name it. You don't expect a single person, or even a whole single company, to be able to do all of those.</p>
</blockquote>
<p><strong>Jack of all trades, master of none.</strong></p>
<p>And there is still more, although it is not exclusive to this profession. You are expected to have not only a wide variety of hard skills but also a bunch of <strong>soft skills</strong> that would make you a solid candidate for "Employee of the Year".</p>
<p>You are expected to be a highly motivated team player with leadership skills; an excellent communicator with both technical and non-technical people; and possess proactivity, the ability to give and receive feedback, a capacity for self-management, abstract thinking, attention to detail, and adaptability to change, among many others.</p>
<p>Naturally, you should be someone with experience working in startups or fast-paced environments, with a business-driven, product-focused mindset, and be able to balance technical debt with delivering new features quickly.</p>
<p>Note that all those skills have been extracted from real job offers I have seen lately.</p>
<p>I love what I do, and the opportunity to constantly learn is one of the best parts of this job. However, I think the industry needs to move away from seeking <a href="https://www.simplethread.com/the-10x-programmer-myth/">mythical 10x engineers</a>.</p>
<p>My suggestion is to not expect to hire a single person to do the work of an entire department, and rely on the candidates' ability to learn and adapt. Foster a culture that values deep expertise as much as broad knowledge to build stronger teams.</p>
<p>Thank you for reading and see you in the next one!</p>
]]></content:encoded></item><item><title><![CDATA[The one about blogging]]></title><description><![CDATA[It has been two years now since I started writing this blog.
The way I see it, maintaining a blog requires discipline. Those who know me well know that discipline is one of my key strengths.
However, ]]></description><link>https://davidmontesdeoca.dev/the-one-about-blogging</link><guid isPermaLink="true">https://davidmontesdeoca.dev/the-one-about-blogging</guid><category><![CDATA[Blogging]]></category><category><![CDATA[blog]]></category><category><![CDATA[development]]></category><category><![CDATA[developers]]></category><category><![CDATA[knowledge]]></category><dc:creator><![CDATA[David Montesdeoca]]></dc:creator><pubDate>Tue, 29 Apr 2025 14:46:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1746204154363/eb94fb98-4299-4505-8cb9-20f9b56cc44d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>It has been two years now since I started writing this blog.</p>
<p>The way I see it, maintaining a blog <strong>requires discipline</strong>. Those who know me well know that discipline is one of my key strengths.</p>
<p>However, I never dared to take the leap before because several developer friends, whom I admire professionally, had tried and quickly fell by the wayside, usually after the first or second post. Why would I be any different?</p>
<p>I knew that if I were to start, I had to take it seriously. Two years later, I can say that, for now, I have achieved what I initially set out to do: <strong>publish at least one post per month</strong>.</p>
<p>It all started with <a href="/the-one-with-access-denied-to-aws-in-production">a post</a> where I talked about an error that caused Domestika application to lose access to AWS S3 in production for a few minutes.</p>
<p>Normally, I would have saved the solution to that error in an Evernote note, including a link to where I found it. I would also save that link as a bookmark. That was my <em>modus operandi</em> for many years.</p>
<p>Over the years, I rarely actually used those notes or bookmarks when I found myself in a similar situation. Typically, when in need of that information again, I had completely forgotten that I had noted it down or saved the bookmark. To be perfectly honest, it is very easy and convenient to perform a quick search and, in fact, I normally ended up on the same Stack Overflow thread where I had already found the solution before.</p>
<p>So why did I keep saving them? Old habits die hard.</p>
<p>My approach now, instead of saving that stuff in notes or bookmarks, is to write a post. Writing a post is completely different. There have been several occasions when I needed something I had previously written about, and I always come directly to the blog to see what I wrote instead of doing a quick search.</p>
<p>Do not get me wrong, I still save some notes with details that are not quite enough for a full blog post, but I do it much less than before.</p>
<p>An important decision was also which platform to host my blog on. I was pretty sure I preferred not to create a custom solution when very popular options already exist, especially for developers, like <a href="http://dev.to">dev.to</a> or <a href="https://medium.com/">Medium</a>, which would likely also give me more visibility. However, when I researched the options a bit, I quickly opted for <a href="https://hashnode.com/">Hashnode</a>. Without getting into too many comparisons, it is exactly what I was looking for: the ability to easily export my posts, write in Markdown, back up to a GitHub repository, add a custom domain, among many other things. Also, in my opinion, it is a platform with a much more attractive look and feel than other options. And, as if that were not enough, Hashnode has a <a href="https://apidocs.hashnode.com/">public API</a> that lets you interact with it.</p>
<p>Choosing the language to write in represented another important decision. Spanish is my mother tongue, thus it is the language where I feel most at ease. Furthermore, while Spanish content is growing, there is significantly less of it compared to English, presenting a chance for potentially greater impact on the Spanish-speaking community. Still, I knew the best path for me was writing in English, intending to improve my <strong>writing skills</strong> and aiming to become a better <strong>storyteller</strong> in that language.</p>
<p>The only post I have written in both languages so far is the one about <a href="/el-de-mi-experiencia-en-domestika">my experience at Domestika</a>. The reason was that some news had appeared in the press that, as far as I knew, were not entirely true and I wanted to avoid anything getting <em>lost in translation</em>.</p>
<p>Without a doubt, I still have room for improvement in that area, although I feel more comfortable over time. Sometimes, I still find myself writing the text in Spanish initially, as it comes more fluidly, before translating it into English. This approach, however, involves a considerably larger investment of time compared to writing directly in English.</p>
<p>On the one hand, I write for myself for the reasons I already explained, aiming to keep useful stuff accessible, functioning somewhat like a diary or logbook. While I was job hunting last year, every selection process involved questions about <a href="/the-one-about-mentoring-junior-developers">how I do certain things</a>, <a href="/the-one-about-my-favorite-project-so-far">projects I have worked on</a>, or <a href="/the-one-about-my-experience-working-remotely">my experience in general</a>. As time goes by, remembering specific details becomes harder, so I am sure having a post with lots of details will help me be more precise in future selection processes. That is also why I decided to write <a href="/series/working-for-an-american-fintech">the series about my experience working for an American fintech as a contractor</a>.</p>
<p>On the other hand, I like the idea of <a href="https://endler.dev/2025/best-programmers/#write">sharing knowledge</a>. If it is useful for me, it might be useful for others too. I am aware I do not have a huge audience; only <a href="/the-one-with-a-large-project-in-a-github-repository">a few</a> <a href="/the-one-with-openssl-issues-installing-older-ruby-versions-on-ubuntu-2204">posts</a> <a href="/the-one-with-a-mouse-jiggler-in-ubuntu">have had</a> <a href="/the-one-about-working-with-macos-being-a-linux-user">some impact</a> (according to analytics), but I am proud that the post where I talk about <a href="/the-one-about-learning-ruby">how to learn Ruby</a> was included in the <a href="https://newsletter.shortruby.com/p/edition-120">Short Ruby Newsletter</a>. Visits to that post increased considerably after that feature.</p>
<p><strong>Maintaining a blog takes more time than I initially thought</strong>. When you have the chance to write about something you have done recently, it is usually quite straightforward, especially for purely technical posts. However, for various reasons, writing about what you do day-to-day is not always possible, either because I have not finished it by the time I want to write the next post or due to confidentiality. It is in those moments that I turn to a note I keep in Notion where I jot down ideas I think might be interesting.</p>
<p>Once I decide what I am going to write about, the next thing I do is make a list of points I want to include in the post. After that, it is time to shape all those ideas so they make narrative sense and are not just a jumble of loose thoughts. This is probably the phase that takes me the longest.</p>
<p>I usually write at the end of my workday, especially when I can finish earlier. I try not to spend more than a couple of hours each time, because I do not like spending the whole day in front of the computer.</p>
<p>In the future, I would like to write more purely technical posts, because I think those are the ones I enjoy writing the most, and at the same time, they are the ones I feel are most useful. Especially when it comes to writing about <a href="/the-one-about-conditionals-in-ruby">my approach to a certain topic</a> or about <a href="/the-one-with-access-denied-to-aws-in-production">a mistake made and what I learned from it</a>.</p>
<p>And that is basically my process for maintaining this blog.</p>
<p>Thank you for reading and see you in the next one!</p>
]]></content:encoded></item><item><title><![CDATA[The one about layoffs in an American fintech]]></title><description><![CDATA[Shortly after I published a post about my experience working for an American fintech, the company send a message to the whole company via Slack.
For obvious reasons, I will not share that message, but]]></description><link>https://davidmontesdeoca.dev/the-one-about-layoffs-in-an-american-fintech</link><guid isPermaLink="true">https://davidmontesdeoca.dev/the-one-about-layoffs-in-an-american-fintech</guid><category><![CDATA[fintech]]></category><category><![CDATA[fintech software development]]></category><category><![CDATA[Layoffs]]></category><category><![CDATA[layoff]]></category><dc:creator><![CDATA[David Montesdeoca]]></dc:creator><pubDate>Sun, 30 Mar 2025 14:29:24 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1742316733298/fe6db248-50e6-4f1a-b15f-3215702b6fa7.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Shortly after I published <a href="/the-one-about-my-experience-working-for-an-american-fintech-6-months">a post about my experience working for an American fintech</a>, the company send a message to the whole company via Slack.</p>
<p>For obvious reasons, I will not share that message, but I would like to share some highlights:</p>
<ul>
<li><p>The revenue growth was slowed in 2024, so to keep growing the company must adapt.</p>
</li>
<li><p>The company decided to restructure teams, and as a result, approximately 120 positions (both employees and contractor roles) will be eliminated globally, affecting around 10% of the organization.</p>
</li>
<li><p>The company announced the acquisition of a leading software provided to the travel and hospitality industry for over $300M, further helping to diversify the business.</p>
</li>
<li><p>All employees will receive an email stating whether their role was impacted by that restructure.</p>
</li>
</ul>
<p>That day I started working later than usual and the first thing I noticed was a Slack message from my engineering manager (EM) asking me if I had received the email, although he mentioned the communication plan might differ for contractors.</p>
<p>However, some colleagues from SNGULAR unexpectedly received that email, informing them their role was not impacted. I will get back to this later.</p>
<p>This whole situation took everyone off guard, since it was a decision coming from the top management of the company in the USA. Not even our team managers knew about it, so they had no answers to give to all of us.</p>
<p>Tying all the loose ends, for a few weeks before the big announcement they had been gradually installing <a href="https://www.kandji.io/">Kandji</a>, an automation-forward Apple device management (MDM) software, to remotely control our laptops. That software works like a middleware even in the login process of the operating system, so if they remove your access to the project the laptop is basically useless.</p>
<p>In this case, SNGULAR knew nothing about it and even worse, my manager told us he was hiring people to join to this project. So weird.</p>
<p>An <em>All Hands Engineering</em> meeting was scheduled that same day, where the CTO of the company answered some of the questions that were sent anonymously. Not much was clarified at that meeting.</p>
<p>One of the items he did confirm though was that a dozen contractors would stop working on the project and that they were already deciding who would be affected.</p>
<p>Of course, not all contractors affected by the restructure are from SNGULAR, as the client collaborates with other consulting firms as well. But we did not know who nor how many of us would be affected.</p>
<p>The very same day the layoffs were announced, some colleagues from other areas of the company no longer had access to the project. In the engineering area, however not many people was affected, especially in the <em>Transactional</em> area, of which my team is part of.</p>
<p>According to what I later found out, there are several reasons why they could not do the same to everybody else affected by these layoffs, mainly due to the laws of each country:</p>
<ul>
<li><p>In Spain, the standard notice period for terminating an employment contract is 15 days, applicable to both employers and employees, but it is common for companies to terminate employees without prior notice, assuming a severance pay.</p>
</li>
<li><p>In some cases, it was necessary to wait to remove the access to the project to workers in other time zones.</p>
</li>
<li><p>In other cases, there was a <a href="https://www.gov.uk/redundancy-your-rights/consultation">consultation period</a>.</p>
</li>
<li><p>In the case of contractors, it depends on the notice period agreed with the consulting firm.</p>
</li>
</ul>
<p>In our next <em>1 to 1</em> meeting, the EM confirmed that 2 or 3 people from SNGULAR would left the project, but they did not know who exactly yet. He also told me that I was among the top contractors in terms of performance and implied that he did not expect any changes concerning my position, but could not guarantee that I would not be affected. I felt somewhat relieved in that moment, although it was not yet confirmed.</p>
<p>A couple of weeks later, in my monthly <em>1 to 1</em> meeting with the manager from SNGULAR, he confirmed that two colleagues would left the project at the end of the month and they had already been notified. In that moment, he did not know if more people would be also affected.</p>
<p>Surprisingly, one of the colleagues who had received the email stating their role was not impacted, was one of the people affected. D'oh!</p>
<p>Since the notice period agreed upon with SNGULAR is 3 weeks, my colleagues are still working on the project, until the end of the month. Tough position for them.</p>
<p>Finally, a week later both managers confirmed that the budget for 2025 is set in the fintech company and no further changes are expected, barring a disaster. The EM also confirmed our team will stay the same during Q2.</p>
<p>Although the company's stock price fell by nearly 40% immediately following the release of their previous year's financial results and the announcement of the layoffs. Who knows what is yet to come.</p>
<p>Anyway, I must say that during those weeks, I was very concerned about my future, because I was hired specifically to work for this client and did not feel like working for another one, that would have been the worst case scenario for any of us.</p>
<p>Given the uncertainty, I seriously thought about starting to search for a new job, although I did not feel much like it either.</p>
<p>As I said in <a href="/the-one-about-my-experience-working-for-an-american-fintech-6-months">my previous post</a>, working on this project is allowing me to learn a lot and I want to continue learning a lot more in the upcoming months.</p>
<p>After <a href="/the-one-about-my-experience-at-domestika#heading-the-beginning-of-the-end">my experience at Domestika</a>, going through something like this again is really unpleasant. Luckily, for the time being, it is over.</p>
<p>Thank you for reading, and see you in the next one!</p>
]]></content:encoded></item><item><title><![CDATA[The one about my experience working for an American fintech]]></title><description><![CDATA[In a previous post, I talked about how things work in the American fintech I am currently working for as a contractor.
I initially planned to write this post after my first three months, but I postpon]]></description><link>https://davidmontesdeoca.dev/the-one-about-my-experience-working-for-an-american-fintech-6-months</link><guid isPermaLink="true">https://davidmontesdeoca.dev/the-one-about-my-experience-working-for-an-american-fintech-6-months</guid><category><![CDATA[fintech]]></category><category><![CDATA[fintech software development]]></category><category><![CDATA[software development]]></category><category><![CDATA[Software Engineering]]></category><category><![CDATA[agile]]></category><category><![CDATA[agile development]]></category><dc:creator><![CDATA[David Montesdeoca]]></dc:creator><pubDate>Wed, 26 Feb 2025 17:00:47 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1758564072564/140f54bf-40bc-419c-966c-e665288ab29e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>In a <a href="/the-one-about-how-things-work-in-an-american-fintech">previous post</a>, I talked about how things work in the American fintech I am currently working for as a contractor.</p>
<p>I initially planned to write this post after my first three months, but I postponed it when we learned that all teams in the <em>Transactional</em> area would be reorganized at the start of the year.</p>
<p>This change took me by surprise. I knew that my team had been formed at the beginning of 2024 with members from other teams, but I did not realize this was something the company regularly does at the start of each year. The goal is to <strong>prevent knowledge from becoming siloed within specific teams</strong>.</p>
<p>This time, the teams were restructured based on the <strong>new architecture design</strong>, which was announced at the end of last year in an All Hands Engineering meeting. The goal is to complete it by 2027.</p>
<p>Given all this, I decided to wait until I had first impressions of the new team before writing about my experience. It has now been six months since I started working on this project and one month since the new team was formed.</p>
<p>Let me start from the beginning.</p>
<h2>My first team</h2>
<p>All the details about how that team worked can be found in my <a href="/the-one-about-how-things-work-in-an-american-fintech#heading-my-team">previous post</a>.</p>
<p>Although it might sound like a cliché, I really felt like everyone on the team welcomed me with open arms.</p>
<p>For the first few weeks, I met every morning with my <strong>onboarding buddy</strong>, who explained how each application in the project works. We also spent part of the day <strong>pair programming</strong> on tasks she was working on.</p>
<p>Normally, I would spend the rest of the day completing tasks from the board that I had been assigned as part of the onboarding. I also spent several weeks <a href="/the-one-about-working-with-macos-being-a-linux-user">configuring the macOS laptop</a> they provided me.</p>
<p>During that time, <strong>I had no autonomy and often felt lost</strong>. I had no prior knowledge of the domain, and the project itself is quite complex, requiring many moving parts to come together. Still, my colleagues were incredibly patient and helped me as much as they could, especially when it came to testing tasks before moving them to "Ready for QA".</p>
<p>From the beginning, I noticed the team had been working together for several months because everything ran smoothly. Tasks arrived at refinements with enough detail, making it easy to decide what needed to be done.</p>
<p>I have to say that I integrated perfectly into their workflow. At first, I could barely participate in technical meetings, not due to a lack of technical knowledge, but because I lacked context and did not know the scope of certain changes. Over time, I gradually became more active in these meetings.</p>
<p>After applying several refactors and adding a couple of new features, I gained the trust of my teammates, who started to recognize how I could contribute and I quickly became a reference within the team when working with Ruby and applying best practices, as most of them had more experience with other programming languages. I always felt comfortable expressing my opinions and sharing ideas with them, even when we did not always agree. We would always reach a consensus for the good of the project. For example, I managed to <a href="/the-one-about-linting-in-a-legacy-ruby-project">add RuboCop</a> to all projects under our governance.</p>
<p>For the most part of my career, I have been working with Rails, but I never had the chance to work with Sinatra before. I am enjoying <strong>learning a simpler and lighter framework</strong>, although sometimes it lacks certain useful features, such as many methods available in the <a href="https://api.rubyonrails.org/classes/Time.html">Time class</a>.</p>
<p>That being said, I have often missed having a well-defined company-wide standard so that all teams could follow certain guidelines ensuring we work similarly. I understand this is hard to achieve, but I think it would be great to work on a project this large and always know where to find things, how to name them, what to test, and how to do it. In short, I <strong>miss consistency</strong>.</p>
<p>Regarding security, this project is unlike any othe project I have worked on before. For example, in AWS, we only have read permissions, and we do not have access to the database console in production, which makes perfect sense. Because of this, we <strong>rely heavily on observability</strong>. Any data changes in production are done through Rake tasks, which must be requested via a support ticket.</p>
<p>We must also be extra careful during what they call "peak times", such as Black Friday and Christmas, because breaking something in production could mean certain transactions do not reach their destination on time, potentially involving millions of euros, dollars, or any other currency.</p>
<p>In fact, a couple of months ago, I developed a feature that had to be deployed to production and executed right before Christmas. Of course, <strong>things did not go as planned</strong>. As a result of running thousands of background jobs, response times for one of the most critical tables in one of the most essential applications skyrocketed. I plan to talk about it in a future post.</p>
<p>Thankfully, the issue only affected that table, but I felt terrible about it. <strong>The engineering manager (EM) was very supportive</strong>, telling me that these things happen and that we must learn from them to prevent them to happen again in the future. His words were a huge relief.</p>
<p>As for the rest of the teammates, I have a very good relationship with the two developers I worked with the most, who also live in Madrid. Before Christmas we met in person when another teammate came to town for a conference and I also attended the company's Christmas dinner, where I met several colleagues from other teams.</p>
<h2>My new team</h2>
<p>We started working together in mid-January.</p>
<p>The new team consists of an EM, a project manager (PM), a QA engineer, a tech lead and 5 software engineers, including myself.</p>
<p>I repeat in the team with the EM, the QA engineer and my onboarding buddy.</p>
<p>We follow the same agile ceremonies as before, but one major difference is that we <strong>do not have sprints</strong>. This is an unusual way of working for me, especially since we follow agile principles in almost every other aspect.</p>
<p>Our governance over the different applications in the project is different now, with a couple of exceptions, such as the payouts application that I have mentioned in other posts.</p>
<p>All team members currently live in Spain, so we usually have our daily meetings in the morning and scheduled meetings still happen in the afternoon, such as refinements.</p>
<p>Interestingly, almost half the team works as contractors through SNGULAR.</p>
<p>A role we did not have in my previous team is the tech lead. He has been at the company the longest and ensures everyone understands the overview and implications of new features to guarantee everything fits together correctly.</p>
<p>During the first few days, he organized several workshops to explain the so-called "transactional registry", which is a crucial component of the new architecture.</p>
<p>We also had several <strong>mob programming sessions</strong> where we started developing together the first functionality in that application.</p>
<p>I will not go into much detail about what that service does, but I should mention that its governance belongs to another team. As I mentioned in <a href="/the-one-about-how-things-work-in-an-american-fintech#heading-the-project">another post</a>, that ownership is not strict, as any developer can make changes to any application. Out of courtesy, the team with governance over each application is tagged.</p>
<p>Even though that other team shares with us the EM and the PM, and the tech lead often collaborates with them, we have encountered in a month several occasions where both teams have worked on similar functionality, because of a <strong>lack of proper communication</strong>.</p>
<p>For example, I recently worked on a feature that made certain attributes of an event optional, including them in the payload only if those attributes in the original event published in another application had led to a change in the corresponding object of our application. Normally, such change would involve changing the version of the event, but since no one was consuming the event yet, we decided in the refinement not to change it. However, by the time the feature was developed, reviewed, and tested by QA team, a couple of weeks had passed. When I deployed the changes to production, we suddenly started to see errors in a third application related to that event I had modified.</p>
<p>How was that possible if no one was consuming that event? In the time between our refinement and the deployment to production, another team had created a new event handler in that third application. Fortunately, it was only logging some information, so there was no real impact. This is an example of the lack of communication I mentioned earlier.</p>
<p>We clearly have <strong>room for improvement in terms of task planning</strong>. However, given that we are a newly formed team, it is understandable that these things happen.</p>
<p>That said, our timeline is tight. Ideally, we should <strong>complete several key components of the new architecture in Q1</strong>. The team was formed with that goal in mind.</p>
<p>The EM mentioned that it is likely the team will stay the same during Q2, but beyond that, no one knows what teams there will be or who will be part of each team.</p>
<p>For sure there is a good atmosphere in the team, but we have different points of view on key aspects of our work. This goes beyond just how we write and organize code.</p>
<p>Most of us prefer tasks to be as detailed as possible before refinement, while the tech lead prefers the opposite. I assume we will end up somewhere in between, especially since some of us lack context on the applications we are working with, making it difficult to move forward without enough details.</p>
<p>Our <strong>refinements are taking much longer than usual</strong>. We have scheduled a weekly 1-hour refinement, but we have often extended it for several hours or even continue the next day.</p>
<p>In some cases, a refinement has even resulted in <strong>re-refining some tasks already started</strong> because we totally changed the way we want to approach it. That situation is not sustainable over time and, of course, is an issue that we discussed in our first retrospective meeting.</p>
<p>Everybody in the team respect and follow the tech lead's vision, but right now it is creating a bottleneck, especially regarding how we want to implement certain details of the code. When in doubt, he is obviously the go-to guy.</p>
<p>In the same way we must find our <em>sweetspot</em>, being able to identify where we can be more or less demanding, thus generating some <strong>technical debt</strong> if necessary.</p>
<p>I have to admit that on a day-to-day basis <strong>we do not really feel pressure at all</strong>, but being a new team, it is noticeable that we all want to deliver and sometimes we have not paid enough attention to the whole software quality process and because of that <strong>we have had some unnecessary and relatively important bugs in production</strong>.</p>
<p>During a 1-to-1 meeting, the EM told me that if I ever feel any kind of pressure, I should let him know so we can handle it properly.</p>
<p>However I have noticed that even though we all discuss the progress in our tasks during the daily meeting, the EM sometimes asks me in private about the progress in certain tasks. In some cases, he even have moved a task to a different column on the board before I do.</p>
<p>Even though he tries not to rush us, I get the feeling that he is under pressure from his own manager to ensure we deliver the expected features on time.</p>
<p>On top of that, security restrictions cause additional delays in delivery. For example, to take a new application to production, we must first complete an initial use case and review it with the security team. Once they validate that first use case, the new application becomes available for deployment, and we can then proceed normally with new use cases.</p>
<p>Finally, as for me, I have to admit that <strong>my attention is somewhat divided</strong> because I am still working on a task related to the production incident I mentioned earlier. As I said before, I hope to talk about it soon.</p>
<hr />
<p>These first six months have flown by, and I have to say that not everything has been easy for me. However, the teams I have been part of have done their best to make me feel like one of them, even though I am a contractor.</p>
<p>This is by far <strong>the most complex project I have worked on</strong>. Many times, I have felt lost, but I never hesitated to ask for help, and the team was always there when I needed it.</p>
<p>It is a system where multiple applications interact, meaning <strong>a lot of things can go wrong</strong>. Moreover, this company operates in a completely different way than what I was used to in other projects, as production has far more security constraints than usual. And it makes sense, after all, we are constantly dealing with money, one way or another.</p>
<p>Just when I was starting to get into the rhythm of my first team, to better understand our processes and how our governance applications were connected, we had to split up.</p>
<p>I was really sad when I found out we would not be working together anymore. When I came back from the Christmas break, the team no longer existed. And unfortunately, it seems pretty likely that the same thing will happen again in the next few months.</p>
<p>On the other hand, I am happy because it is a <strong>technical challenge</strong> that is allowing me to learn a lot about the fintech world. That was one of my goals when I started looking for a new job last year.</p>
<p>Right now, it is a matter of being patient, as the beginning is always difficult for everyone. Hopefully, we will find our rhythm soon, and everything will start falling into place.</p>
<p>Thank you for reading, and see you in the next one!</p>
]]></content:encoded></item><item><title><![CDATA[The one about learning Ruby]]></title><description><![CDATA[I started working with Ruby in early 2010, on projects developed with Ruby on Rails. My mentor was influential enough to convince our bosses that this programming language and this framework were the ]]></description><link>https://davidmontesdeoca.dev/the-one-about-learning-ruby</link><guid isPermaLink="true">https://davidmontesdeoca.dev/the-one-about-learning-ruby</guid><category><![CDATA[hanamirb]]></category><category><![CDATA[Ruby]]></category><category><![CDATA[Rails]]></category><category><![CDATA[Ruby on Rails]]></category><category><![CDATA[rubyonrails]]></category><category><![CDATA[sinatrarb]]></category><category><![CDATA[training]]></category><category><![CDATA[learning]]></category><category><![CDATA[backend]]></category><dc:creator><![CDATA[David Montesdeoca]]></dc:creator><pubDate>Sat, 25 Jan 2025 12:08:35 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1737487504845/5aa24c3c-3b31-4dab-9b10-db7981d4b53c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I started working with <a href="https://www.ruby-lang.org/en/">Ruby</a> in early 2010, on projects developed with <a href="https://rubyonrails.org/">Ruby on Rails</a>. My mentor was influential enough to convince our bosses that this programming language and this framework were the best choice for future projects. We usually worked with <a href="https://cakephp.org/">CakePHP</a>, so understanding how Rails works with the <a href="https://developer.mozilla.org/en-US/docs/Glossary/MVC">MVC pattern</a> was really a piece of cake. Got it? Never mind...</p>
<p>15 years later, I work at <a href="https://www.sngular.com/">SNGULAR</a>, which is a consulting firm that mostly works with <a href="https://www.java.com/en/">Java</a> and its ecosystem in the backend of their software development projects. They also have projects with popular languages such as <a href="https://www.php.net/">PHP</a> or <a href="https://nodejs.org/en">Node.js</a>, among others. Focusing exclusively on what they call the <strong>Backend chapter</strong>, each of these technologies has an internal <strong>learning path</strong> defined. This is not the case with Ruby, since they do not have many projects where this is the main programming language.</p>
<p>When I started working at this company I was already assigned to a project, but as I already had booked my vacations, <a href="/the-one-about-my-new-job">my start at the American fintech</a> was delayed for a few weeks until I was back.</p>
<p>I had several weeks ahead of me before leaving, so in addition to completing my onboarding, my boss proposed to do a <strong>Ruby training</strong> to some colleagues who did not have a project assigned at the time. All of them were experienced developers who were used to work mainly with Java or PHP, but had no previous experience with Ruby. I accepted without hesitation.</p>
<p>You will find below the guide I shared with the company to learn Ruby, to which I have been adding some stuff that I consider interesting during the last months.</p>
<h2>Ruby Training</h2>
<h3>Recommendations</h3>
<p>The most important recommendation is always <strong>learn Ruby first and then learn the framework of your choice if needed</strong>, usually the most popular. This is applicable to any ecosystem. That way, when you work on a gem, a script or anything else where you do not have available any of the libraries provided by the framework, you will be able to work with Ruby pretty easily.</p>
<p>Even if you already have experience with this language, I recommend you to access <a href="https://railshurts.com/frames/quiz/">this quiz</a> and you will probably understand what I mean.</p>
<p>Another recommendation that I consider important is that you look at the version of Ruby used in the articles and documentation that you find, because some things might be outdated. On the <a href="https://www.ruby-lang.org/en/">official website</a> you will always find news about the latest versions released.</p>
<h3>Documentation</h3>
<ul>
<li><p><a href="https://docs.ruby-lang.org/en/">Ruby documentation</a></p>
</li>
<li><p><a href="https://rubyapi.org/">Ruby API</a></p>
</li>
<li><p><a href="https://www.ruby-lang.org/en/documentation/ruby-from-other-languages/">Ruby from other languages</a></p>
</li>
<li><p><a href="https://rubyreferences.github.io/rubyref/">The Ruby Reference</a></p>
</li>
<li><p><a href="https://learnxinyminutes.com/ruby/">Learn Ruby</a></p>
</li>
</ul>
<h3>References in the community</h3>
<p>Not an exhaustive list and in no particular order:</p>
<ul>
<li><p><a href="https://en.wikipedia.org/wiki/Yukihiro_Matsumoto">Yukihiro Matsumoto "Matz"</a></p>
</li>
<li><p><a href="https://sandimetz.com/">Sandi Metz</a></p>
</li>
<li><p><a href="https://janko.io/">Janko Marohnić</a></p>
</li>
<li><p><a href="https://www.nateberkopec.com/">Nate Berkopec</a></p>
</li>
<li><p><a href="https://avdi.codes/">Avdi Grimm</a></p>
</li>
<li><p><a href="http://kytrinyx.com/">Katrina Owen</a></p>
</li>
<li><p><a href="https://tenderlovemaking.com/">Aaron Patterson</a></p>
</li>
<li><p><a href="https://www.mikeperham.com/">Mike Perham</a></p>
</li>
<li><p><a href="https://github.com/amatsuda">Akira Matsuda</a></p>
</li>
<li><p><a href="https://github.com/rafaelfranca">Rafael França</a></p>
</li>
<li><p><a href="https://dhh.dk/">DHH</a></p>
</li>
<li><p><a href="https://thoughtbot.com/blog">Thoughtbot</a></p>
</li>
<li><p><a href="https://evilmartians.com/chronicles">Evil Martians</a></p>
</li>
<li><p><a href="https://shopify.engineering/authors/shopify-engineering">Shopify Engineering</a></p>
</li>
<li><p><a href="https://github.blog/tag/ruby/">GitHub</a></p>
</li>
</ul>
<h3>Key concepts</h3>
<p>Going beyond the most basic concepts present in every programming language we can find the following concepts:</p>
<ul>
<li><p><a href="https://en.wikipedia.org/wiki/Global_interpreter_lock">Global Interpreter Lock (GIL)</a>:</p>
<ul>
<li><p>Concurrency vs parallelism</p>
</li>
<li><p>Threads vs Fibers vs Ractors</p>
</li>
</ul>
</li>
<li><p><a href="https://shopify.engineering/ruby-yjit-is-production-ready">YJIT</a>:</p>
<ul>
<li><p>Ruby is an interpreted language</p>
</li>
<li><p>The JIT allows to dynamically compile Ruby code into machine code at runtime without interpretation</p>
</li>
<li><p>Improve performance and memory usage</p>
</li>
</ul>
</li>
<li><p>Types:</p>
<ul>
<li><p>Duck typing vs <code>is_a?</code>/<code>kind_of?</code> vs <code>instance_of?</code></p>
</li>
<li><p>There are <strong>no interfaces</strong></p>
</li>
<li><p>Type checkers:</p>
<ul>
<li><p>Not very extended in the Ruby community</p>
</li>
<li><p><a href="https://sorbet.org/">Sorbet</a></p>
</li>
<li><p><a href="https://github.com/ruby/rbs">RBS</a></p>
</li>
</ul>
</li>
</ul>
</li>
<li><p>Debugging:</p>
<ul>
<li><p><code>&lt;object&gt;.methods.sort</code>: what methods an object respond to.</p>
</li>
<li><p><code>&lt;object&gt;.method(:&lt;method_name&gt;).source_location</code>: where is defined a certain method.</p>
</li>
<li><p><code>&lt;object&gt;.method(:&lt;method_name&gt;).super_method.source_location</code>: where is defined certain method with the same name in one of the ancestors.</p>
</li>
</ul>
</li>
<li><p>Metaprogramming:</p>
<ul>
<li><p><code>define_method</code></p>
</li>
<li><p><a href="https://thoughtbot.com/blog/always-define-respond-to-missing-when-overriding">Always define respond_to_missing? when overriding method_missing</a></p>
</li>
<li><p><code>send</code> vs <code>public_send</code></p>
</li>
<li><p>Monkeypatching</p>
</li>
</ul>
</li>
<li><p><a href="https://www.exceptionalcreatures.com/guides/what-are-ruby-exceptions.html">Exceptions</a>:</p>
<ul>
<li><a href="https://www.exceptionalcreatures.com/guides/what-are-ruby-exceptions.html#the-class-hierarchy">The class hierarchy</a></li>
</ul>
</li>
<li><p><a href="https://docs.ruby-lang.org/en/3.4/syntax/pattern_matching_rdoc.html">Pattern matching</a></p>
</li>
<li><p><a href="https://dev.to/baweaver/ruby-3-1-shorthand-hash-syntax-first-impressions-19op">Shorthand hash syntax</a></p>
</li>
<li><p><a href="https://thoughtbot.com/blog/ruby-safe-navigation">Safe navigation operator (&amp;)</a></p>
</li>
<li><p>Strings (<code>to_s</code>) vs symbols (<code>to_sym</code>):</p>
<ul>
<li><a href="https://ruby-doc.org/3.3.3/syntax/comments_rdoc.html#label-frozen_string_literal+Directive">Frozen strings</a></li>
</ul>
</li>
<li><p>Classes vs modules</p>
</li>
<li><p>Lambdas vs procs vs blocks</p>
</li>
<li><p>Memoization:</p>
<ul>
<li><a href="https://dev.to/codeandclay/a-memoization-gotcha-14c6">Beware booleans</a></li>
</ul>
</li>
<li><p>Logical operators:</p>
<ul>
<li><p><code>and</code> vs <code>&amp;&amp;</code></p>
</li>
<li><p><code>or</code> vs <code>||</code></p>
</li>
<li><p><code>not</code> vs <code>!</code></p>
</li>
</ul>
</li>
<li><p>Namespaces</p>
</li>
<li><p>Inheritance</p>
</li>
<li><p>Constants lookup</p>
</li>
<li><p><code>super</code></p>
</li>
<li><p><code>self</code></p>
</li>
<li><p>Attribute macros:</p>
<ul>
<li><code>attr_reader</code> vs <code>attr_writer</code> vs <code>attr_accessor</code></li>
</ul>
</li>
<li><p>Variables:</p>
<ul>
<li>local vs global vs instance variables vs class instance variables vs class variables</li>
</ul>
</li>
<li><p>Methods:</p>
<ul>
<li><p>Visibility: <strong>public</strong> vs <code>protected</code> vs <code>private</code></p>
</li>
<li><p><a href="https://allaboutcoding.ghinda.com/endless-method-a-quick-intro">Endless</a></p>
</li>
</ul>
</li>
<li><p>Ranges:</p>
<ul>
<li><a href="https://docs.ruby-lang.org/en/3.4/Range.html#class-Range-label-Beginless+Ranges">Beginless</a> and <a href="https://docs.ruby-lang.org/en/3.4/Range.html#class-Range-label-Endless+Ranges">endless</a></li>
</ul>
</li>
<li><p>Regular expressions:</p>
<ul>
<li><a href="https://rubular.com/">Rubular Playground</a></li>
</ul>
</li>
<li><p>Documentation:</p>
<ul>
<li><a href="https://rubydoc.info/gems/yard/file/docs/GettingStarted.md">YARD</a></li>
</ul>
</li>
</ul>
<h3>Frameworks</h3>
<ul>
<li><p><a href="https://rubyonrails.org/">Ruby on Rails</a>:</p>
<ul>
<li><p><a href="https://rubyonrails.org/docs">Documentation</a></p>
</li>
<li><p><a href="https://guides.rubyonrails.org/">Guides</a></p>
</li>
<li><p><a href="https://rubyonrails.org/doctrine">The Rails Doctrine</a></p>
</li>
</ul>
</li>
<li><p><a href="https://hanamirb.org/">Hanami</a>:</p>
<ul>
<li><p><a href="https://docs.hanamirb.org">Documentation</a></p>
</li>
<li><p><a href="https://guides.hanamirb.org/">Guides</a></p>
</li>
</ul>
</li>
<li><p><a href="https://sinatrarb.com/">Sinatra</a>:</p>
<ul>
<li><a href="https://sinatrarb.com/documentation.html">Documentation</a></li>
</ul>
</li>
<li><p><a href="https://padrinorb.com/">Padrino</a>:</p>
<ul>
<li><p><a href="https://www.rubydoc.info/github/padrino/padrino-framework">Documentation</a></p>
</li>
<li><p><a href="https://padrinorb.com/guides/">Guides</a></p>
</li>
</ul>
</li>
<li><p><a href="https://brutrb.com/">BrutRB</a>:</p>
<ul>
<li><a href="https://brutrb.com/getting-started.html">Documentation</a></li>
</ul>
</li>
</ul>
<h3>Testing</h3>
<ul>
<li><p>Frameworks:</p>
<ul>
<li><p><a href="https://github.com/minitest/minitest">Minitest</a> (default):</p>
<ul>
<li><p><a href="https://docs.seattlerb.org/minitest/">Documentation</a></p>
</li>
<li><p><a href="https://github.com/thoughtbot/rails-training-testing-exercise/">Learn how to write tests with Minitest</a></p>
</li>
</ul>
</li>
<li><p><a href="https://rspec.info/">RSpec</a>:</p>
<ul>
<li><p><a href="https://rspec.info/documentation/">Documentation</a></p>
</li>
<li><p><a href="https://rspec.info/blog/">Blog</a></p>
</li>
</ul>
</li>
<li><p><a href="https://cucumber.io/">Cucumber</a>:</p>
<ul>
<li><p><a href="https://cucumber.io/docs/">Documentation</a></p>
</li>
<li><p><a href="https://cucumber.io/learn/">Learn BDD and Cucumber</a></p>
</li>
<li><p><a href="https://cucumber.io/blog/">Blog</a></p>
</li>
</ul>
</li>
</ul>
</li>
<li><p>Articles:</p>
<ul>
<li><p><a href="https://thoughtbot.com/blog/how-to-train-your-senior-developers-in-ruby-on-rails#testing-ruby-for-beginners">Testing Ruby for beginners</a></p>
</li>
<li><p><a href="https://thoughtbot.com/blog/the-case-for-wet-tests">The Case for WET Tests</a></p>
</li>
<li><p><a href="https://thoughtbot.com/blog/mystery-guest">Mystery Guest</a></p>
</li>
<li><p><a href="https://thoughtbot.com/blog/functional-viewpoints-on-testing-objectoriented-code">Testing Objects with a Functional Mindset</a></p>
</li>
<li><p><a href="https://thoughtbot.com/blog/four-phase-test">Four-Phase Test</a></p>
</li>
<li><p><a href="https://thoughtbot.com/blog/write-reliable-asynchronous-integration-tests-with-capybara">Write Reliable, Asynchronous Integration Tests With Capybara</a></p>
</li>
<li><p><a href="https://thoughtbot.com/blog/tags/testing">Thoughtbot</a> (lots of articles)</p>
</li>
</ul>
</li>
</ul>
<h3>Interpreters</h3>
<ul>
<li><p><a href="https://github.com/ruby/ruby">Matz's Ruby Interpreter a.k.a. Ruby MRI a.k.a. CRuby</a></p>
</li>
<li><p><a href="https://github.com/jruby/jruby">JRuby</a></p>
</li>
<li><p><a href="https://github.com/codicoscepticos/ruby-implementations">Others</a></p>
</li>
</ul>
<h3>Installation</h3>
<ul>
<li><p>In a real project, whenever possible, it is recommended to use Docker containers.</p>
</li>
<li><p>Version managers:</p>
<ul>
<li><p><a href="https://rvm.io/rvm/install#any-other-system">RVM</a></p>
<ul>
<li><p><code>.ruby-version</code> (include in the version control)</p>
</li>
<li><p><code>.ruby-gemset</code> (do not include in the version control: Add to <code>.gitignore</code>)</p>
</li>
</ul>
</li>
<li><p><a href="https://asdf-vm.com/guide/getting-started.html#_3-install-asdf">asdf</a></p>
</li>
<li><p><a href="https://rbenv.org/">rbenv</a></p>
</li>
<li><p><a href="https://github.com/postmodern/chruby">chruby</a></p>
</li>
</ul>
</li>
<li><p><a href="https://gorails.com/setup/">GoRails setup</a></p>
</li>
</ul>
<h3>Editors and IDEs</h3>
<p>You will find plugins for Ruby in any editor/IDE.</p>
<ul>
<li><p><a href="https://www.jetbrains.com/ruby/">RubyMine</a>: the best integration</p>
</li>
<li><p><a href="https://code.visualstudio.com/docs/languages/ruby">VSCode</a></p>
<ul>
<li><p>Add a language server protocol (LSP):</p>
<ul>
<li><p><a href="https://shopify.github.io/ruby-lsp/">Ruby LSP</a></p>
</li>
<li><p><a href="https://marketplace.visualstudio.com/items?itemName=Shopify.ruby-lsp">Extension</a></p>
</li>
</ul>
</li>
</ul>
</li>
</ul>
<h3>Linters</h3>
<ul>
<li><p><a href="https://rubocop.org/">RuboCop</a></p>
<ul>
<li><a href="/the-one-about-linting-in-a-legacy-ruby-project">How to start using a linter in a legacy project</a></li>
</ul>
</li>
<li><p><a href="https://github.com/standardrb/standard">Standard</a></p>
</li>
<li><p><a href="https://brakemanscanner.org/">Brakeman</a></p>
</li>
<li><p><a href="https://github.com/troessner/reek">Reek</a></p>
</li>
</ul>
<h3>Style guides</h3>
<ul>
<li><p><a href="https://github.com/rubocop/ruby-style-guide">Ruby</a></p>
</li>
<li><p><a href="https://github.com/rubocop/rails-style-guide">Rails</a></p>
</li>
<li><p><a href="https://github.com/rubocop/rspec-style-guide">RSpec</a></p>
</li>
<li><p><a href="https://ruby.style/">Ruby in Style</a></p>
</li>
</ul>
<h3>Playgrounds</h3>
<ul>
<li><p><a href="https://try.ruby-lang.org/">Try Ruby</a></p>
</li>
<li><p><a href="https://ruby.github.io/play-ruby/">Ruby Playground</a></p>
</li>
<li><p><a href="https://ruby-next.github.io/">Ruby Next Playground</a></p>
</li>
<li><p><a href="https://runruby.dev/">RunRuby.dev</a></p>
</li>
</ul>
<h3>Courses</h3>
<p>The company provide us with access to the courses catalog available on Udemy.</p>
<p>Some interesting courses to start learning Ruby and Rails:</p>
<ul>
<li><p><a href="https://www.udemy.com/course/learn-to-code-with-ruby-lang/">Learn to Code with Ruby</a></p>
</li>
<li><p><a href="https://www.udemy.com/course/testing-ruby-with-rspec/">Testing Ruby with RSpec</a></p>
</li>
<li><p><a href="https://www.udemy.com/course/the-complete-ruby-on-rails-developer-course/">The Complete Ruby on Rails Developer Course</a></p>
</li>
<li><p><a href="https://www.udemy.com/course/building-instagram-from-scratch-using-ruby-on-rails-7/">How To Build Instagram Clone Using Ruby on Rails 7</a></p>
</li>
</ul>
<p>Not yet available for us, but hopefully will be soon:</p>
<ul>
<li><a href="https://www.udemy.com/course/ruby-on-types-write-robust-software-with-rbs">Ruby on Types: Static Typing with Ruby and Ruby on Rails</a></li>
</ul>
<p>Other courses:</p>
<ul>
<li><p><a href="https://www.rubycademy.com/">RubyCademy</a></p>
</li>
<li><p><a href="https://www.theodinproject.com/paths/full-stack-ruby-on-rails">The Odin Project: Full Stack Ruby on Rails</a>:</p>
<ul>
<li><p><a href="https://www.theodinproject.com/paths/full-stack-ruby-on-rails/courses/ruby">Ruby</a></p>
</li>
<li><p><a href="https://www.theodinproject.com/paths/full-stack-ruby-on-rails/courses/ruby-on-rails">Rails</a></p>
</li>
</ul>
</li>
</ul>
<h3>Videos</h3>
<ul>
<li><p><a href="https://www.rubyvideo.dev/">Ruby conferences</a></p>
</li>
<li><p><a href="https://thoughtbot.com/upcase/rails">Upcase</a></p>
</li>
<li><p><a href="https://www.driftingruby.com/">Drifting Ruby</a></p>
</li>
<li><p><a href="https://gorails.com/episodes">GoRails</a></p>
</li>
<li><p><a href="http://railscasts.com/">Railscasts</a> (outdated)</p>
</li>
</ul>
<h3>Books</h3>
<ul>
<li><p><a href="https://pragprog.com/search/?q=ruby">The Pragmatic Bookshelf</a>: lots of books could be outdated</p>
<ul>
<li><p><a href="https://pragprog.com/titles/ruby5/programming-ruby-3-3-5th-edition/">Programming Ruby 3.3</a></p>
</li>
<li><p><a href="https://pragprog.com/titles/rails7/agile-web-development-with-rails-7/">Agile Web Development with Rails 7</a></p>
</li>
</ul>
</li>
<li><p><a href="https://www.amazon.com/gp/product/0134456475">Practical Object-Oriented Design: An Agile Primer Using Ruby (POODR)</a></p>
</li>
<li><p><a href="https://sandimetz.com/99bottles">99 Bottles of OOP</a></p>
</li>
<li><p><a href="https://books.thoughtbot.com/books/ruby-science.html">Ruby Science</a></p>
</li>
<li><p><a href="https://painlessrails.com/">Painless Rails</a></p>
</li>
<li><p><a href="https://www.railsspeed.com/">The Complete Guide to Rails Performance</a></p>
</li>
<li><p><a href="https://books.thoughtbot.com/books/testing-rails.html">Testing Rails</a></p>
</li>
</ul>
<h3>Newsletters</h3>
<ul>
<li><p><a href="https://rubyweekly.com/">Ruby Weekly</a></p>
</li>
<li><p><a href="https://newsletter.shortruby.com/">Short Ruby</a></p>
</li>
<li><p><a href="https://rubycentral.org/news/">Ruby Central</a></p>
</li>
</ul>
<h3>Exercises and katas</h3>
<ul>
<li><p><a href="https://exercism.org/tracks/ruby">Exercism</a></p>
</li>
<li><p><a href="https://www.codewars.com/kata">Codewars</a></p>
</li>
<li><p><a href="https://leetcode.com/problemset/">Leetcode</a></p>
</li>
</ul>
<h3>Interesting websites and articles</h3>
<ul>
<li><p><a href="https://phptoruby.io/">PHP to Ruby</a></p>
</li>
<li><p><a href="https://www.rubyguides.com/ruby-post-index/">RubyGuides</a></p>
</li>
<li><p><a href="https://refactoring.guru/design-patterns">Design Patterns</a>: every pattern includes examples in Ruby</p>
</li>
<li><p><a href="https://gorails.com/guides">GoRails guides</a></p>
</li>
<li><p><a href="https://railshurts.com/">Rails hurts</a></p>
</li>
<li><p><a href="https://github.com/backpackerhh/upgrow-docs">Upgrow: A Better Architecture</a>:</p>
<ul>
<li><p>Proposed by Shopify and somehow inspired by <a href="/the-one-with-highlights-of-the-red-book-of-ddd">DDD</a></p>
</li>
<li><p>Bear in mind that this is not a usual approach in a Rails project</p>
</li>
</ul>
</li>
<li><p><a href="https://thoughtbot.com/blog/how-to-train-your-senior-developers-in-ruby-on-rails">How to train senior developers in Ruby on Rails</a></p>
</li>
<li><p><a href="https://about.gitlab.com/blog/2022/07/06/why-were-sticking-with-ruby-on-rails/">GitLab: Why we're sticking with Ruby on Rails</a></p>
</li>
<li><p><a href="https://thoughtbot.com/blog/writing-less-error-prone-code">Writing Less Error-Prone Code</a></p>
</li>
</ul>
<h3>Useful and popular gems</h3>
<ul>
<li><p><a href="https://rubygems.org/">RubyGems</a>: Find, install, and publish Ruby gems</p>
</li>
<li><p><a href="https://ruby.libhunt.com/">Awesome Ruby</a>: A collection of awesome Ruby gems, tools, frameworks and software</p>
</li>
<li><p><a href="https://www.ruby-toolbox.com/">The Ruby Toolbox</a>: Find actively maintained &amp; popular open source software libraries for the Ruby programming language</p>
</li>
</ul>
<p>Some gems are available for any Ruby project and others are only available for Rails:</p>
<ul>
<li><p>Testing:</p>
<ul>
<li><p><a href="https://github.com/rspec/rspec">RSpec</a></p>
</li>
<li><p><a href="https://github.com/minitest/minitest">Minitest</a></p>
</li>
<li><p><a href="https://github.com/cucumber/cucumber-ruby">Cucumber</a></p>
</li>
<li><p><a href="https://github.com/thoughtbot/factory_bot">FactoryBot</a></p>
</li>
<li><p><a href="https://github.com/teamcapybara/capybara">Capybara</a></p>
</li>
<li><p><a href="https://github.com/vcr/vcr">VCR</a></p>
</li>
<li><p><a href="https://github.com/rswag/rswag">RSwag</a>/<a href="https://github.com/raceful-potato/rspec-swag">rspec-swag</a></p>
</li>
<li><p><a href="https://github.com/bblimke/webmock">Webmock</a></p>
</li>
<li><p><a href="https://github.com/faker-ruby/faker">Faker</a></p>
</li>
<li><p><a href="https://github.com/DatabaseCleaner/database_cleaner">database-cleaner</a></p>
</li>
</ul>
</li>
<li><p>Next generation:</p>
<ul>
<li><a href="https://github.com/dry-rb">dry</a> (dependency injection, types, schemas, validations, monads, ...)</li>
</ul>
</li>
<li><p>Web servers:</p>
<ul>
<li><a href="https://github.com/rack/rack">Rack</a> (Interface)</li>
</ul>
</li>
<li><p>Application servers:</p>
<ul>
<li><p><a href="https://github.com/puma/puma">puma</a></p>
</li>
<li><p><a href="https://github.com/phusion/passenger">passenger</a></p>
</li>
</ul>
</li>
<li><p>CORS:</p>
<ul>
<li><a href="https://github.com/cyu/rack-cors">rack-cors</a></li>
</ul>
</li>
<li><p>Databases:</p>
<ul>
<li><p><a href="https://github.com/ged/ruby-pg">PostgreSQL</a></p>
</li>
<li><p><a href="https://github.com/brianmario/mysql2">MySQL</a></p>
</li>
<li><p><a href="https://github.com/mongodb/mongo-ruby-driver">MongoDB</a></p>
</li>
<li><p><a href="https://github.com/redis/redis-rb">Redis</a></p>
</li>
</ul>
</li>
<li><p>Migrations:</p>
<ul>
<li><a href="https://github.com/ankane/strong_migrations">Strong migrations</a></li>
</ul>
</li>
<li><p>Bulk imports:</p>
<ul>
<li><a href="https://github.com/zdennis/activerecord-import">ActiveRecord Import</a></li>
</ul>
</li>
<li><p>Cloud:</p>
<ul>
<li><p><a href="https://github.com/aws/aws-sdk-ruby">AWS</a></p>
</li>
<li><p><a href="https://github.com/googleapis/google-cloud-ruby">GCP</a></p>
</li>
<li><p><a href="https://github.com/Azure/azure-storage-ruby">Azure</a> (no longer maintained)</p>
</li>
</ul>
</li>
<li><p>Background jobs:</p>
<ul>
<li><p><a href="https://github.com/sidekiq/sidekiq">Sidekiq</a></p>
</li>
<li><p><a href="https://github.com/resque/resque">Resque</a></p>
</li>
<li><p><a href="https://github.com/collectiveidea/delayed_job">Delayed Jobs</a></p>
</li>
</ul>
</li>
<li><p>Events:</p>
<ul>
<li><p><a href="https://github.com/ruby-amqp/bunny">Bunny</a> (RabbitMQ)</p>
</li>
<li><p><a href="https://github.com/ruby-amqp/kicks">Kicks</a> (RabbitMQ)</p>
</li>
<li><p><a href="https://github.com/karafka/karafka">Karafka</a>: (Kafka)</p>
</li>
</ul>
</li>
<li><p>Cron:</p>
<ul>
<li><p><a href="https://github.com/javan/whenever">whenever</a></p>
</li>
<li><p><a href="https://github.com/jmettraux/rufus-scheduler">rufus-scheduler</a></p>
</li>
<li><p><a href="https://github.com/sidekiq-cron/sidekiq-cron">Sidekiq Cron</a></p>
</li>
<li><p><a href="https://github.com/sidekiq-scheduler/sidekiq-scheduler">sidekiq-scheduler</a></p>
</li>
<li><p><a href="https://github.com/resque/resque-scheduler">resque-scheduler</a></p>
</li>
</ul>
</li>
<li><p>WebSocket:</p>
<ul>
<li><a href="https://github.com/anycable/anycable">AnyCable</a></li>
</ul>
</li>
<li><p>Uploads:</p>
<ul>
<li><p><a href="https://github.com/shrinerb/shrine">Shrine</a></p>
</li>
<li><p><a href="https://github.com/carrierwaveuploader/carrierwave">Carrierwave</a></p>
</li>
</ul>
</li>
<li><p>Caching:</p>
<ul>
<li><a href="https://github.com/petergoldstein/dalli">Dalli</a> (Memcached)</li>
</ul>
</li>
<li><p>Full text search:</p>
<ul>
<li><p><a href="https://github.com/elastic/elasticsearch-ruby">Elasticsearch</a></p>
</li>
<li><p><a href="https://github.com/opensearch-project/opensearch-ruby">OpenSearch</a></p>
</li>
<li><p><a href="https://github.com/algolia/algoliasearch-client-ruby">Algolia</a></p>
</li>
</ul>
</li>
<li><p>HTTP client:</p>
<ul>
<li><p><a href="https://github.com/lostisland/faraday">Faraday</a></p>
</li>
<li><p><a href="https://github.com/jnunemaker/httparty">httparty</a></p>
</li>
</ul>
</li>
<li><p>APIs:</p>
<ul>
<li><p><a href="https://github.com/rmosolgo/graphql-ruby">GraphQL</a></p>
</li>
<li><p><a href="https://github.com/jsonapi-serializer/jsonapi-serializer">JSON:API serializer</a></p>
</li>
<li><p><a href="https://github.com/rails-api/active_model_serializers">ActiveModel serializers</a></p>
</li>
<li><p><a href="https://github.com/jwt/ruby-jwt">JWT</a></p>
</li>
</ul>
</li>
<li><p>Deployment:</p>
<ul>
<li><a href="https://github.com/capistrano/capistrano">Capistrano</a></li>
</ul>
</li>
<li><p>Geocoding:</p>
<ul>
<li><a href="https://github.com/alexreisner/geocoder">Geocoder</a></li>
</ul>
</li>
<li><p>Feature flags:</p>
<ul>
<li><a href="https://github.com/flippercloud/flipper">Flipper</a></li>
</ul>
</li>
<li><p>Authentication:</p>
<ul>
<li><p><a href="https://github.com/heartcombo/devise">Devise</a></p>
</li>
<li><p><a href="https://github.com/doorkeeper-gem/doorkeeper">Doorkeeper</a></p>
</li>
<li><p><a href="https://github.com/omniauth/omniauth">Omniauth</a>: Standardized Multi-Provider Authentication</p>
</li>
<li><p><a href="https://github.com/thoughtbot/clearance">Clearance</a></p>
</li>
</ul>
</li>
<li><p>Authorization:</p>
<ul>
<li><p><a href="https://github.com/varvet/pundit">Pundit</a></p>
</li>
<li><p><a href="https://github.com/cancancommunity/cancancan">CanCanCan</a></p>
</li>
<li><p><a href="https://github.com/palkan/action_policy">ActionPolicy</a></p>
</li>
</ul>
</li>
<li><p>Mobile:</p>
<ul>
<li>Hotwire Native: <a href="https://github.com/hotwired/hotwire-native-android">Android</a>/<a href="https://github.com/hotwired/hotwire-native-ios">iOS</a></li>
</ul>
</li>
<li><p>Templates:</p>
<ul>
<li><p><a href="https://github.com/ruby/erb">ERB</a></p>
</li>
<li><p><a href="https://github.com/haml/haml">Haml</a></p>
</li>
<li><p><a href="https://github.com/slim-template/slim">Slim</a></p>
</li>
</ul>
</li>
<li><p>Assets:</p>
<ul>
<li><p><a href="https://github.com/sass-contrib/sass-embedded-host-ruby">Sass</a></p>
</li>
<li><p><a href="https://github.com/rails/tailwindcss-rails">Tailwind</a></p>
</li>
<li><p><a href="https://github.com/twbs/bootstrap-rubygem">Bootstrap</a></p>
</li>
<li><p><a href="https://github.com/reactjs/react-rails">React</a></p>
</li>
<li><p><a href="https://github.com/bkuhlmann/htmx">htmx</a></p>
</li>
</ul>
</li>
<li><p>Interactivity:</p>
<ul>
<li><p><a href="https://github.com/hotwired/stimulus-rails">Stimulus</a></p>
</li>
<li><p><a href="https://github.com/hotwired/turbo-rails">Turbo</a></p>
</li>
</ul>
</li>
<li><p>Views:</p>
<ul>
<li><a href="https://viewcomponent.org/">View Component</a></li>
</ul>
</li>
<li><p>Forms:</p>
<ul>
<li><a href="https://github.com/heartcombo/simple_form">Simple Form</a></li>
</ul>
</li>
<li><p>Pagination:</p>
<ul>
<li><p><a href="https://github.com/kaminari/kaminari">Kaminari</a></p>
</li>
<li><p><a href="https://github.com/mislav/will_paginate">will_paginate</a></p>
</li>
<li><p><a href="https://github.com/ddnexus/pagy">pagy</a></p>
</li>
</ul>
</li>
<li><p>State machines:</p>
<ul>
<li><p><a href="https://github.com/state-machines/state_machines">State Machines</a></p>
</li>
<li><p><a href="https://github.com/aasm/aasm">AASM</a></p>
</li>
</ul>
</li>
<li><p>Videos:</p>
<ul>
<li><a href="https://github.com/sethdeckard/m3u8">m3u8</a></li>
</ul>
</li>
<li><p>Money:</p>
<ul>
<li><a href="https://github.com/RubyMoney/money">money</a></li>
</ul>
</li>
<li><p>Audit changes:</p>
<ul>
<li><a href="https://github.com/collectiveidea/audited">audited</a></li>
</ul>
</li>
<li><p>Dependency updates:</p>
<ul>
<li><a href="https://github.com/renovatebot/renovate">Renovate</a></li>
</ul>
</li>
</ul>
<h3>Community</h3>
<p>Last but not least, get involved in the community the way you prefer. Follow people in social networks, read as much as you can about the language, its frameworks and tools, and even collaborate in open source projects if you feel like it.</p>
<p>In your city probably there is a Ruby group that gathers from time to time, usually monthly, to talk about the language and related stuff. For instance, in Madrid, where I am currently living, we have <a href="https://www.madridrb.com/">Madrid.rb</a>.</p>
<p>Recently, I found <a href="https://rubyfriends.app/">RubyFriends</a>, an app that allows people to reconnect after a conference. A great idea!</p>
<hr />
<p>That training for me was an enriching experience that took about 4 weeks. My colleagues approached the training as a challenge and at all times I felt that they were really interested in learning the language and its ecosystem, and that they wanted to put into practice what they were learning.</p>
<p>My boss told me that they were very happy with the training and I can confirm the guide I created will be used for future trainings at SNGULAR.</p>
<p>I would also like to take this opportunity to thank my mentor, <a href="https://www.linkedin.com/in/eddyjosafat">Eddy Josafat</a>, for coming up with the brilliant idea of using this language in which he saw a promising future. He was not wrong.</p>
<p>By the way, this is a list that I will keep updating over time. Is there anything you miss in this list? If so, leave it in the comments, please.</p>
<p>Thank you for reading and see you in the next one!</p>
]]></content:encoded></item></channel></rss>