{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "Reepolee - Blog",
  "description": "Blog - Reepolee",
  "home_page_url": "https://www.reepolee.com/engineering-notes/",
  "feed_url": "https://www.reepolee.com/engineering-notes/feed.json",
  "language": "en-US",
  "items": [
    {
      "id": "https://www.reepolee.com/engineering-notes/why-scripting/",
      "url": "https://www.reepolee.com/engineering-notes/why-scripting/",
      "title": "Why Scripting?",
      "summary": "Why Reepolee builds with plain TypeScript scripts: they let platform quirks be absorbed into readable code you own, in one place, rather than leaking into configuration, plugins, or setup instructions.",
      "content_html": "<p>The Reepolee render pipeline is a set of TypeScript files. <code>bun scripts/ssg.ts</code>. <code>bun scripts/generate_sitemap.ts</code>. <code>bun scripts/prepare_images.ts</code>. <code>bun scripts/git_commit_push.ts</code>. The <code>prod</code> target, which takes a project from source to deployed, chains them together in three commands: <code>bun rel &amp;&amp; bun ssg &amp;&amp; bun git:commit-push</code>. No Makefile, no YAML pipeline that runs locally, no task runner configuration with a plugin system and its own versioning concerns. Just scripts, in the same language as everything else, doing the work directly.</p>\n<p>People sometimes ask why not a task runner, and there is a perfectly good ecosystem of them, mature and well understood, so the question is fair. But the comparison is not really the interesting part, because the choice of scripts over a task runner is a small consequence of a larger preference, and the larger preference is this: we want the awkward, platform-specific parts of a build to live inside code we can read, in the same language as everything else, rather than leaking out into configuration, plugin behavior, or the assumption that a particular binary happens to be installed on the machine. A script is worth reaching for not because it has fewer moving parts than a task runner, although it does, but because it is the place where a platform quirk can be absorbed, named, and made to behave the same everywhere, which is the thing I find myself valuing more the longer a project lives.</p>\n<hr />\n<h2 id=\"the-quirks-live-in-code-where-you-can-see-them\">The Quirks Live in Code, Where You Can See Them</h2>\n<p>Every build eventually collides with something that is not the same on every machine, because the shell is different on Windows than it is on a Mac, because a path separator is a backslash in one place and a slash in another, because an image encoder that was fast on the developer's laptop turns out to depend on a native binary that the CI runner does not have, and because the version of a tool that was installed globally last year is not the version installed globally this year. These are the parts of a build that quietly consume an afternoon, and the question that matters is not whether they exist, because they always do, but where they are allowed to live.</p>\n<p>When the build is a task runner with plugins, the quirk has to be handled somewhere outside the code you wrote, which usually means a plugin's own assumptions, a configuration flag that papers over the difference, or a note in a README telling the next person to install something first. When the build is a script, the quirk lives in a function you can open and read, given a name that says what it is working around, so that the workaround is visible instead of implicit and can be changed by the same person who found the problem.</p>\n<p>This is the real reason we use Bun's native APIs throughout, and not merely to avoid a dependency. <code>Bun.$</code> is not a wrapper around the system shell but its own parser and interpreter, so the shell grammar we write, the redirects and pipes and quoting and variable expansion, is the same on Windows as it is on a Linux runner, and a handful of the usual Unix commands like <code>ls</code>, <code>rm</code>, <code>mkdir</code>, <code>cat</code> and <code>which</code> are built into it, which means the scripts do not reach for <code>rimraf</code> or <code>cross-env</code> or assume coreutils is present to do ordinary file wrangling. This does not make every command portable, because the moment a script shells out to a genuinely platform-specific external binary it is as platform-dependent as it would be anywhere else, so the win is bounded but real: the shell language itself, and the everyday commands, stop being a source of difference between machines. <code>Bun.file</code> and <code>Bun.write</code> read and write without a stream-handling library and without the differences between how one platform buffers and another flushes. <code>Bun.Image</code> powers the image pipeline that used to need <code>sharp</code> and the native binary it drags along, which is the exact kind of dependency that works everywhere until the one time it does not, on a new architecture or an unfamiliar CI image, at which point the failure is somebody else's compiled code and not something you can read. Folding the encoder into the runtime means the pipeline runs anywhere Bun runs, and when it behaves oddly the behavior is in a <code>.ts</code> file we own rather than behind a binary we do not.</p>\n<p>The gain is not that the quirks disappear, because they do not, but that they are collected into readable code in one place, named for what they are, and owned by the people who maintain the rest of the application, instead of being scattered across plugin defaults and setup instructions where nobody quite remembers why they are there.</p>\n<hr />\n<h2 id=\"simplicity-is-the-wrong-word-but-it-points-at-the-right-thing\">Simplicity Is the Wrong Word, But It Points at the Right Thing</h2>\n<p>When people say a script is &quot;simpler&quot; than a task runner, what they usually mean is that a script has fewer concepts in it, and that is true but not quite the point. A task runner has a model of its own, whether that is a configuration object with a particular shape that its plugins expect, or a pipeline of sources and sinks and transforms chained together, and while none of these are complicated in themselves, they are <em>different</em>, different enough that when you come back to a project after six months you are not just reading code but decoding the runner's model before you can understand what the build actually does.</p>\n<p>A TypeScript script has no such model. It is just code, and the code does what it says. The <code>git_commit_push.ts</code> script stages everything, checks if there is anything staged, and either commits and pushes or exits cleanly, because that is what those thirty lines say when you read them top to bottom, without knowing anything about the tool that runs them except that it is Bun. The grammar is the language you already know, which means the script is legible to anyone who can read TypeScript and is running for the first time in the same amount of time it would take for someone who has been maintaining it for years.</p>\n<hr />\n<h2 id=\"updates-are-yours-to-make\">Updates Are Yours to Make</h2>\n<p>The same legibility matters most at the moment something needs to change, and something always needs to change, eventually, because the build needs a new step, or an existing step needs a different flag, or a script that was correct for one environment needs to be correct for two. When the build lives in a configuration layer that is not quite code, the change is a change to that layer, and the review becomes a review of intent rather than behavior, because tracing exactly what will happen means also knowing the behavior of whatever runs the configuration, which usually lives somewhere the reviewer has not read.</p>\n<p>With a script, a change to the build is a change to TypeScript. The diff is TypeScript. The reviewer reads TypeScript. When the <code>generate_sitemap.ts</code> script needed to handle the <code>localize: false</code> frontmatter key to skip alternate-language <code>&lt;url&gt;</code> entries for non-localized pages, the change was two dozen lines of TypeScript in the script itself, the diff was exactly what it looks like, and anyone who has read the sitemap spec knows whether the behavior is correct just by reading the code that produces it.</p>\n<p>There is a subtler version of this that matters during development: scripts can be run directly, with arguments, from the terminal, because they are just programs. <code>bun scripts/generate_sitemap.ts --site-url https://staging.example.com --dist ./dist-staging</code> is a command you can run when you're debugging the sitemap output for a new deployment environment. No task runner invocation, no intermediate configuration layer, just the script with the flags it already knows about because they are parsed in a function at the top of the file.</p>\n<hr />\n<h2 id=\"scripts-tell-you-what-they-did\">Scripts Tell You What They Did</h2>\n<p>There is another reason, about verifiability, and I think it is the one that is hardest to articulate until you've been burned by its absence.</p>\n<p>A build step that succeeds silently has told you nothing. A build step that prints what it did has given you a log you can reason about, share with a colleague, and compare against the last run to understand what changed. Every script in the Reepolee pipeline prints its work: the sitemap script prints the number of URLs included and the number of pages skipped and why, the RSS script prints the language and the number of items for each feed, the image script prints every file it processed and whether it was a cache hit or a full re-encode. The <code>═══</code> separator and the final summary block are not decoration but a deliberate choice to make the output readable in a terminal, skimmable in a CI log, and easy to parse by eye when something unexpected happens.</p>\n<p>The <code>vendor_check.ts</code> script extends this further. Vendored JavaScript libraries, such as a validation library or a syntax highlighter, are committed to the repository as static files, which means the version that runs in production is pinned to exactly what was reviewed and committed. The vendor check reads the version embedded in each file's header, reads the version recorded in the <code>package.json</code> download script, and tells you whether they match, and it does not fix anything but reports and leaves the human to decide, which is the distinction that makes the script verifiable rather than merely automated.</p>\n<p>The engine drift check is the most elaborate version of this pattern we have. The template engine is shared upstream code, and the script that checks for drift does something that I find genuinely elegant: it transpiles both the local copy and the canonical copy with Bun's built-in TypeScript transpiler, strips whitespace and trailing commas, and compares the results, which means two files that are formatted differently but logically identical produce the same fingerprint and are not flagged as drifted, while two files that have diverged in behavior are caught even if the divergence is subtle. When there is a difference, the script writes a unified diff to <code>engine_drift.diff</code> and exits nonzero, so the CI fails and the developer reads the diff, because the tool found something a human needs to look at and handed it off rather than trying to resolve it on its own.</p>\n<blockquote>\n<p>When a tool decides on its own, you inherit whatever it decided without ever seeing it; when it reports and stops, the decision stays with you, and that is the difference we keep reaching for.</p>\n</blockquote>\n<hr />\n<h2 id=\"scripts-know-the-domain\">Scripts Know the Domain</h2>\n<p>One thing that would be lost with an external task runner is the ability for scripts to import from the application's own libraries, and that ability turns out to matter more than it sounds.</p>\n<p>The <code>generate_sitemap.ts</code> script is not a standalone tool that treats the application as a black box. It imports <code>$lib/static_site</code> to enumerate the page files. It imports <code>$lib/i18n</code> to load translations and resolve localized route names. It imports <code>$lib/content_visibility</code> to apply the same draft and future-date filtering rules that the dev server applies, so the sitemap sees the same pages the site actually serves. The RSS generator does the same, calling into <code>$lib/collect_records</code> with the same arguments the blog index page uses to sort and filter posts.</p>\n<p>This means the scripts and the application share a model of what the project is, and changes to that model, whether a new frontmatter field, a new language, or a change to how routes are resolved, propagate to the scripts through the same import, automatically, without a separate integration layer. The scripts are not shims around the application but participants in it, which is only possible because they are written in the same language, import from the same paths, and run in the same runtime.</p>\n<p>Anything that drives the build from outside the application loses this, because an external tool runs commands across a process boundary, which means whatever the application knows has to be re-expressed as flags or configuration the tool can read. We have seen projects where the list of supported languages appears in three places, in the application code, in the i18n build script, and in a CI matrix, all of which need to stay in sync and all of which drifted at some point, because there is no single source they all read from. With scripts that import from the application, the list appears once, in the configuration module, and everything that needs it reads it from there.</p>\n<hr />\n<h2 id=\"the-generator-is-a-script-too\">The Generator Is a Script Too</h2>\n<p>The clearest illustration of all of this is not in the build pipeline at all but in the code generator, which is the tool that scaffolds a CRUD resource, a route, or a page from the database, and which is itself nothing more than <code>bun generator/reeman.ts</code>. It opens as a terminal menu, but underneath it is the same shape as everything else, a thin entry point that delegates to small flow modules, one per thing it knows how to do, with no framework underneath and nothing to learn beyond the code that is there.</p>\n<p>What makes the generator worth describing here is what it takes as input, because it does not read a schema file that someone wrote down and hopes is current, it reads the live database, introspecting every table, column, and foreign key at startup so that what it generates matches the database that actually exists rather than a description of it that may have drifted. This is the same idea as the sitemap script importing the application's own libraries, carried to its natural end: the tool's input is the running system, so the generated CRUD, the route wiring, and the translation scaffolding are correct by construction, and when the schema changes the generator sees the change the next time it runs because it looked at the database, not at a copy of what the database used to be. The flows even reach back into the application afterwards, importing the same live-reload notify that the dev server uses, so a freshly generated route shows up in the running site without a restart, which is only possible because the generator is a participant in the application and not a separate program pointed at it from outside.</p>\n<p>The maintenance tools in the same menu are where the &quot;reports, human decides&quot; discipline shows up again, and this time with the database as the thing at stake. The translation prune tool scans every <code>.ree</code> template for the keys they actually reference, compares that against the keys in the database, and writes a timestamped <code>.sql</code> file of <code>DELETE</code> statements for you to read before anything is removed, while the domain-compliance check introspects the live schema, flags the columns that do not match the canonical type taxonomy, and offers to generate an <code>ALTER TABLE</code> script rather than altering anything itself. In both cases the script does the tedious, error-prone scanning and comparison that a human should not do by hand, and then it stops at exactly the point where judgment is required, handing over a reviewable artifact instead of quietly changing the database, because a tool that rewrites your schema while you are not looking is not a tool you can trust, and a tool that hands you the SQL and lets you read it is.</p>\n<hr />\n<h2 id=\"the-prod-pipeline-in-practice\">The <code>prod</code> Pipeline in Practice</h2>\n<p>What this looks like in practice is a <code>prod</code> target that reads: <code>bun rel &amp;&amp; bun ssg &amp;&amp; bun git:commit-push</code>. The <code>ssg</code> step runs the image pipeline, the CSS compilation, the static site generator, the sitemap, and the RSS feeds in sequence. The <code>git:commit-push</code> step stages all changes, checks whether there is anything to commit, exits cleanly if the working tree is already clean so the command is safe to run repeatedly without producing empty commits, and otherwise commits and pushes.</p>\n<p>Three commands. Each does one clearly named thing. Each is a TypeScript file you can open, read from top to bottom, and understand without consulting documentation beyond what TypeScript itself requires. When the sitemap is wrong, you open <code>generate_sitemap.ts</code>. When the build is slow, you open <code>ssg/pipeline.ts</code>. When the git step fails, you read the thirty lines of <code>git_commit_push.ts</code> and the answer is in there.</p>\n<p>That legibility, the ability to read the tool and understand it, is what we mean when we say scripting is verifiable. Not just that the output is honest, but that the mechanism is open, in the same place as everything else, maintained by the same people, and subject to the same review process as the application itself. There is no build system to upgrade, no plugin compatibility matrix to manage, no documentation to consult that lives somewhere other than the source file you already have open.</p>\n<p>It is a small preference with a large surface area. We have found it works well.</p>\n",
      "date_published": "2026-07-07T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/boring-ui-wins/",
      "url": "https://www.reepolee.com/engineering-notes/boring-ui-wins/",
      "title": "Boring UI Wins",
      "summary": "On why standardized, predictable UI wins over the life of a product - covering component learning curves, layout conventions, browser-native features, accessibility, maintenance debt, and why the best interface is the one nobody notices.",
      "content_html": "<p>There is a strong pull in software development toward custom UI, because custom UI looks better in demos, photographs well, and impresses in design reviews in a way that a standard form never will. A bespoke component library signals craft, and craft signals care, and care signals that the team is serious about what they are building. All of that is true, and none of it is what the person trying to file the expense report is thinking about.</p>\n<p>What they are thinking about is whether it works the way they expect it to work, and whether they can finish the task and get back to the rest of their day.</p>\n<hr />\n<h2 id=\"the-learning-curve-is-small-until-it-isnt\">The Learning Curve Is Small Until It Isn't</h2>\n<p>Standard UI components - the browser's native input, select, checkbox, date picker, and button - have a learning curve of approximately zero for anyone who has used a computer in the last twenty years. They know how a dropdown opens. They know that tabbing moves focus forward. They know that pressing space on a focused checkbox toggles it. They know that their password manager will offer to fill the username and password fields. They know all of this without being taught, because they have been using these patterns since before they used your application.</p>\n<p>When you replace a native select with a custom-built one, you have introduced a learning curve that did not exist before. It may be shallow - most users will figure it out quickly - but it is not zero, and it is not shared across every application they use. Your custom select behaves the way your team decided it should behave, which is not necessarily the way any other select they have ever used behaves. The user discovers this at the moment they try to interact with it, which is also the moment they were trying to accomplish something else. The learning happens as interruption.</p>\n<p>The gap is even wider for users who rely on keyboard navigation, who use a screen reader, or who have motor impairments that make precise mouse interactions difficult. For these users the &quot;small&quot; learning curve you introduced may not be small at all, because the native element was already optimized for their workflow by a team whose entire job was optimizing for accessibility, and your replacement starts from scratch.</p>\n<hr />\n<h2 id=\"the-browser-is-already-doing-work-you-would-have-to-replicate\">The Browser Is Already Doing Work You Would Have to Replicate</h2>\n<p>A native text input is not a bare element. It carries with it spell checking, autocorrect on mobile, the correct virtual keyboard for the input type (numeric for <code>type=&quot;number&quot;</code>, email keyboard for <code>type=&quot;email&quot;</code>, phone layout for <code>type=&quot;tel&quot;</code>), autofill from the browser's saved form data, password manager integration, undo and redo history, text selection behavior, drag and drop, and accessibility semantics that assistive technologies understand without any additional markup from you.</p>\n<p>None of this is free to replicate. A custom input component that tries to match the behavior of the native one has to implement each of these features, handle each of the edge cases the browser has already handled, test across browsers and operating systems and devices, and then maintain all of that across the life of the project. The native element gets all of it automatically, because the browser team has been working on it for decades and will continue working on it after your project ships, updating it for new devices and new standards and new accessibility requirements without any action on your part.</p>\n<p>The custom date picker is the canonical example of how this plays out. Building one that handles keyboard navigation, screen reader announcements, locale-aware formatting, mobile input, and all the edge cases around month boundaries, leap years, and time zones is a substantial engineering project. The native <code>&lt;input type=&quot;date&quot;&gt;</code> handles most of it by default - imperfectly, inconsistently across browsers, with styling constraints that frustrate designers - but correctly, for free, forever. The effort required to meaningfully exceed the native element's utility usually exceeds the effort that went into the feature it sits inside.</p>\n<hr />\n<h2 id=\"no-surprises-is-a-feature\">No Surprises Is a Feature</h2>\n<p>Predictability is underrated in UI design because it doesn't photograph well. A form that works exactly like every other form the user has ever filled out does not generate enthusiasm in a design review. It generates no reaction at all, which is precisely what you want from it.</p>\n<p>Users build mental models of software quickly and unconsciously. When an interface behaves consistently with their mental model, they move through it without friction, without conscious thought, and without anything they would describe as a &quot;user experience.&quot; The interface disappears. When the interface surprises them - even pleasantly, even with a clever microinteraction they will later describe as &quot;nice&quot; - it momentarily surfaces the interface itself, pulling their attention away from the task. Enough surprises and the interface never fully disappears. The user is always slightly aware of it, slightly uncertain about what the next click will do.</p>\n<p>Trust in software is built through consistency. The user who finds that a button behaves the way they expected it to, that a form validates the way they expected it to, and that an error message tells them what they expected it to tell them, is a user who is building a model of the application that they can rely on. That model is the foundation of confidence, and confidence is what makes people use software without hesitation, which is what makes software feel fast even when the performance metrics are identical.</p>\n<hr />\n<h2 id=\"the-layout-has-already-been-figured-out\">The Layout Has Already Been Figured Out</h2>\n<p>The predictability argument applies not just to individual components but to the whole application - where things are placed, how navigation is structured, what appears at the top level and what is buried. These spatial conventions exist because they were iterated into place over decades of users using software and settling, through sheer accumulated habit, on where they expect to find things. The login link is top right. The logo links back to the home page. Search lives in the header. Destructive actions require confirmation. Settings are one level deep from the account menu, not four. These are not arbitrary - they are the residue of an enormous amount of implicit user research conducted by every application that has ever shipped and had to earn its users' trust.</p>\n<p>When a design breaks one of these conventions in the name of originality, the user pays the cost. Not a large cost in isolation - a few seconds of scanning, a moment of mild confusion - but a cost paid every single time that user wants to do that thing, until they have re-learned the new location, which is work they should not have had to do. The login link that requires a hunt is not a design statement. It is a failure to respect that the user came to log in, not to admire the navigation.</p>\n<p>The same logic applies to the depth at which things live. Actions a user performs every day belong at the surface of the application, reachable in one or two clicks from wherever they are. Actions a user performs once a quarter belong in settings. The frequency of a task and the effort required to reach it should move together, and when they don't - when the thing the user needs most is three levels deep behind a gear icon inside a sidebar that only appears after hover - the application is structured around how the team thinks about the feature, not around how the user uses it. Those are different things, and only one of them matters.</p>\n<p>The hardest part of this for developers and designers is that conventional layout feels like a failure of creativity. The grid of navigation items in the standard position, the account menu where every account menu is, the breadcrumb where breadcrumbs go - none of it is exciting to design, and none of it will appear in an awards submission. But the user who opens the application for the first time and immediately knows where everything is has had an experience that is difficult to achieve and easy to destroy, and the cost of destroying it is not paid by the design team. It is paid, in small increments, by every user who has to hunt for something they expected to find.</p>\n<hr />\n<h2 id=\"accessibility-without-extra-effort\">Accessibility Without Extra Effort</h2>\n<p>The accessibility case for standard UI is not complicated: native elements are accessible by default, and custom elements require deliberate effort to make accessible, effort that is easy to underestimate and easy to get wrong.</p>\n<p>A native <code>&lt;button&gt;</code> is focusable, activatable by keyboard, announced correctly by screen readers, and handled by accessibility APIs across every platform the browser supports. A <code>&lt;div&gt;</code> that looks like a button and responds to click events is none of those things until you add <code>role=&quot;button&quot;</code>, <code>tabindex=&quot;0&quot;</code>, keyboard event handlers for space and enter, and aria attributes that communicate state to assistive technologies - and even then, you are replicating behavior the browser provides natively, with more surface area for mistakes.</p>\n<p>The same pattern applies to form controls, navigation, modals, tooltips, and virtually every interactive component in a typical application. Building them from scratch and making them fully accessible is not impossible, but it requires sustained attention to a problem that most product teams are not primarily focused on, and the result is rarely as complete as what the browser provides for free. The standard component is accessible because its accessibility was the primary concern of the people who built it. The custom component is accessible only to the extent that accessibility was in scope when the feature was built, which is often not far enough.</p>\n<hr />\n<h2 id=\"custom-components-accumulate-debt\">Custom Components Accumulate Debt</h2>\n<p>A component that ships is a component that needs to be maintained. A bug report arrives because it behaves unexpectedly in a browser version that didn't exist when it was built. A new design system update changes the token it relied on. A dependency it used internally reaches end of life. An accessibility audit finds that it fails a WCAG criterion that nobody caught during review. A new device has a screen size that breaks its layout in a way nobody anticipated.</p>\n<p>Each of these is a maintenance event that the native equivalent would not have generated. The browser component is updated by the browser vendor, transparently, as part of the browser release cycle, and the fix reaches every user without any action from the application team. The custom component requires someone on the team to diagnose the issue, implement a fix, test it across the relevant combinations of browser and device, and deploy it - for every incident, for the life of the project.</p>\n<p>This cost is invisible when the component is built because the maintenance horizon is abstract. It becomes visible over three, four, five years of a codebase that has accumulated a library of custom components, each with its own issue history, each requiring occasional attention, each representing a dependency on the team's ability to understand and modify code that was written by a developer who may no longer be there.</p>\n<hr />\n<h2 id=\"the-wow-fades-the-friction-stays\">The Wow Fades. The Friction Stays.</h2>\n<p>There is good research behind the observation that users adapt quickly to visual delight and remember friction for much longer. A clever animation is noticed the first time, appreciated the third time, and invisible by the tenth, because the brain has filed it as expected behavior and stopped allocating attention to it. An unexpected interaction that caused confusion on the first encounter tends to produce caution on subsequent encounters, even after the user has learned to navigate it correctly.</p>\n<p>This asymmetry matters when evaluating the tradeoff between standard and custom UI. The upside of a custom component - the moment of &quot;this feels nice&quot; that it produces in a new user - is transient. The downside - the extra cognitive load of a component that doesn't behave like the user's existing mental models - persists. Not necessarily as active frustration, but as a background cost that shapes how the user experiences the application over time.</p>\n<blockquote>\n<p>The applications that people describe as &quot;just working&quot; are rarely the ones with the most impressive component libraries.</p>\n</blockquote>\n<p>They are the ones where the interface got out of the way and let the user do what they came to do.</p>\n<hr />\n<h2 id=\"what-boring-actually-looks-like-in-practice\">What &quot;Boring&quot; Actually Looks Like in Practice</h2>\n<p>Boring UI is not unstyled UI. A standard select element can be given a typeface, a border, a background color, a focus ring that matches the application's design language - it can look completely native to the product while remaining completely native to the browser. The constraint is not on appearance but on behavior: the element still opens like a select, still supports keyboard navigation like a select, still integrates with autofill and accessibility tools like a select.</p>\n<p>What it means in practice is spending design effort on things that compound - color, typography, spacing, information hierarchy, the clarity of labels and error messages - rather than on things that have to be rebuilt every time the browser changes. It means accepting that some controls will look slightly different across operating systems, because the browser renders them in a way that matches the user's platform, and that consistency with the user's platform is more valuable than pixel-perfect uniformity across all platforms. It means treating the browser as a partner that has already solved a large number of hard problems, rather than as a canvas that needs to be painted over.</p>\n<p>The applications we build for small organizations - credit unions, alumni associations, veterinary practices, volunteer networks - need to be usable by people who are not particularly technical, who may be using them infrequently, and who have no patience for a UI that requires them to learn how it works before they can accomplish anything. For these contexts, a form that looks and behaves exactly like a form is not a failure of design ambition. It is the correct response to what the user actually needs, and it will still be working correctly in five years without anyone having to touch it.</p>\n<p>Boring, in this context, is not a compromise. It is the goal.</p>\n",
      "date_published": "2026-06-27T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/generator-pattern-not-libs/",
      "url": "https://www.reepolee.com/engineering-notes/generator-pattern-not-libs/",
      "title": "The Generator Pattern, Not a Library",
      "summary": "Why Reepolee uses a code generator instead of a library, and what that choice means for security, maintainability, and the surface of change in your own project.",
      "content_html": "<p>When I first described Reepolee to people, the question I heard most often was some version of: why isn't this a library? You could publish it to npm, users install it, they get updates automatically, everyone's happy. It's the familiar model. It's how most tools in this space work. Why do something different?</p>\n<p>Before the answer, a question worth sitting with: why do you update a library in the first place? Sometimes there's a real reason - a security fix, a behavior you actually need, a bug that has been biting you. But a lot of the time the reason is vaguer than that. The version is outdated. <code>npm audit</code> is complaining. A colleague said you should stay current. The new version adds features you haven't asked for and won't use. The honest answer, more often than people admit, is that the update is driven by anxiety about being behind - not by a concrete need. And yet the update carries real risk: changed behavior, broken assumptions, an afternoon spent tracing a regression to a changelog entry you didn't read carefully enough. A tool that requires you to pull a new version deliberately, and review what changed before accepting it, naturally filters out the anxious updates. You only update when you have a reason.</p>\n<p>The answer to the original question has several parts, and none of them are about aesthetics or philosophy for its own sake. They are practical, and they come from watching libraries fail in ways that generators don't.</p>\n<hr />\n<h2 id=\"the-surface-of-change\">The Surface of Change</h2>\n<p>A library has a surface that is hidden from you by design, so when a new version ships the diff is inside someone else's repository, you see a version number change in your <code>package.json</code>, you run the update, you hope nothing broke, and if something did break you find out at runtime, or in a failing test, or from a user who found it first.</p>\n<p>The generator pattern is different. When I update the Reepolee generator and you pull the new version, you run it against your project. It produces changes. Those changes are visible - in your working directory, in your diff, in your version control before anything is committed. You read them. You decide whether they make sense in your context. You take what you need and leave what you don't - if the generator updated the route handler but you've already customized yours in a way that works better for your case, you skip that file. You commit what you're satisfied with.</p>\n<p>The surface of change is entirely visible because the change is <em>your code</em> now, not a library call or a version number, but actual lines in files you own and can read.</p>\n<p>This might sound slower, and in one narrow sense it is. Accepting a library update is one command. Reviewing generated code takes a few minutes. But the narrow sense is wrong about what &quot;slower&quot; costs you. A library update that silently changes behavior costs you a debugging session at the worst possible time. A generated change you reviewed and understood costs you nothing after you commit it, because you know what it does and why it's there.</p>\n<p>There's a smaller version of the same benefit in code review. When a library updates in a pull request, a reviewer sees a version bump - one line changed in <code>package.json</code>. When generated code changes, the reviewer sees actual lines of TypeScript. They can read the diff, understand what changed, and raise a question if something looks off. That conversation - &quot;this changes how we handle null in the request parser, is that intentional?&quot; - doesn't happen with a version number. The review is real because the change is visible.</p>\n<hr />\n<h2 id=\"proximity\">Proximity</h2>\n<p>There is a concept in architecture that I keep returning to when I think about codebases: proximity. Code that is close to where it runs is easier to reason about, easier to debug, and easier to trust. Code that is far away - behind an abstraction, behind a package boundary, behind a network call - is code you are reasoning about indirectly, through its interface rather than its implementation.</p>\n<p>Libraries are architecturally distant because the function you call is not in your repository, its behavior is not something you can read without leaving your codebase, and when it does something unexpected the first step in understanding why is always to go somewhere else, to the library's repository, its changelog, its issue tracker.</p>\n<p>Generated code is architecturally close because the template rendering function is right there in your project, in a file you can open, and if it does something unexpected you can put a breakpoint in it. You can read the implementation. You can change it if your requirements diverge from the assumptions the generator made. You own it in a way that has real meaning: not just licensing, but understanding.</p>\n<p>I've worked in codebases where a critical piece of behavior lived deep inside a library that nobody on the team had ever read. When it broke, it broke strangely - because the failure mode was in code nobody had mental models of. The fix required someone to read the library source, understand its internal state management, and work around it from the outside. That is a tax. A recurring tax. And it compounds as the library grows and the behavior you depend on moves further from your reach.</p>\n<p>This proximity also shows up in stack traces, so that when something breaks at runtime, in staging, in production, at midnight, the stack trace points to your files. Every frame is something you wrote, in a location you recognize, with context you have. With library code, the stack descends into <code>node_modules</code>, into function names that mean nothing until you've read the library's internals. Generated code keeps the entire call stack legible. You debug what you own.</p>\n<hr />\n<h2 id=\"security-is-local\">Security Is Local</h2>\n<p>The supply chain problem in JavaScript is not new, and it is not going away. When you install a library, you are installing its dependencies, and their dependencies, and so on. The code that ends up running in your application is far larger than what you see in your own <code>package.json</code>. Most of it was written by people you have never heard of, maintained by people you have never talked to, and it runs with full access to your file system, your environment variables, and your network. <a href=\"/blog/no-deps-no-build\">The post on zero-dependency builds</a> goes into the specifics of how this plays out in practice - this section focuses on what the generator pattern changes about that picture.</p>\n<p>Generators produce code that lives in your repository, and once generated that code is yours, with no runtime dependency on the generator, no phoning home, no self-updating, no tree of transitive dependencies pulled in when your server starts, because what is in your repository is what runs and nothing else, which is a meaningful difference and not a theoretical one. Auditing a generated codebase means reading your own files. Auditing a library's transitive dependencies means hoping that someone else's <code>npm audit</code> caught everything. The practical difference in how much you actually know about what is running in production is large.</p>\n<blockquote>\n<p>The code you can read is the code you can trust.</p>\n</blockquote>\n<p>When the authentication handler is a file in your project, you have read it. When it's a library call, you have read its API documentation - which is not the same thing.</p>\n<p>This matters even more when a formal security audit is involved. Outside agencies scope their work against the repository. With a deep <code>node_modules</code> tree, the scope question becomes uncomfortable: the transitive dependencies are technically the largest part of what runs in production, but auditing them thoroughly at any reasonable cost is not realistic. Agencies typically exclude them, which means the biggest attack surface gets the least scrutiny. With vendored files, the scope is unambiguous - here are the three external files the application uses, they are committed, they have not changed since they were reviewed. An auditor can read them in an afternoon. And when the audit produces a finding in the generated application code, you can fix it directly, in your own file, and close the finding cleanly. A finding in a library dependency sends you to an issue tracker and a waiting game.</p>\n<hr />\n<h2 id=\"trusted-code-changes\">Trusted Code Changes</h2>\n<p>There is a specific failure mode that generators are immune to and libraries are not: the trusted-code update.</p>\n<p>This is when a library you depend on, one you have used for years, one with a good reputation and a careful maintainer - gets updated. Not compromised, not abandoned. Just updated. The new version changes a behavior that you relied on. Maybe the change is correct by the library's own contracts. Maybe it fixes a bug that your code happened to depend on. Maybe it adds a feature that has a side effect on the path your application uses. You upgrade because you should stay current, and something breaks in a way that takes time to trace back to the update.</p>\n<p>This is not a horror story but routine software maintenance, and it is expensive precisely because it is routine. The cost is not one incident - it is the background tax on every upgrade cycle, the defensive testing you have to do before accepting a new version, the caution that makes people stay on old versions too long, which then creates its own problems.</p>\n<p>Generated code doesn't update unless you regenerate and review, which means it doesn't change in ways you haven't seen. The behavior you have today is the behavior you have tomorrow, and if you want different behavior you make a deliberate decision to change it. No automatic side effects from someone else's release schedule.</p>\n<hr />\n<h2 id=\"what-the-generator-actually-produces\">What the Generator Actually Produces</h2>\n<p>When Reepolee's generator runs, it produces files - route handlers, template utilities, a server entry point, database helpers, configuration - and these are not magic but ordinary, readable, formatted Bun-native TypeScript that a developer new to the project can open and understand without first understanding the generator that produced it. A developer new to the project can open any file and understand what it does without first understanding the generator that produced it.</p>\n<p>The generator is a tool for starting and updating - not a runtime dependency. Once the code exists in your project, the generator could disappear tomorrow and your application would continue to work. That's the relationship we want between a scaffolding tool and the application it creates.</p>\n<p>It also means that when your requirements diverge from what the generator assumes - because they will, eventually - you modify your files directly. You don't fork the library or wait for a configuration option to be exposed or open an issue that may or may not be addressed. You change the code. It's yours.</p>\n<p>The same applies when you keep your project in sync with a newer version of the Reepolee generator. You pull the update, run it, review the diff, and take what makes sense for your project. If a new version improves the authentication flow but you've already rewritten yours, you skip that part. If the updated database helper is an improvement you want, you take it. There is no all-or-nothing upgrade, no compatibility matrix to navigate. You apply what you need, leave what you don't, and your working application stays exactly that - working.</p>\n<hr />\n<h2 id=\"vendor-updates-work-the-same-way\">Vendor Updates Work the Same Way</h2>\n<p>The same logic applies to the third-party libraries that do appear in an Reepolee project. These are vendored: a single minified ESM file, downloaded once with an explicit version number in the command, committed to the repository. Updating Zod means changing <code>zod@4.4.3</code> to <code>zod@4.5.0</code> in the <code>get:zod</code> script, running it, and getting a new file. That file lands in your working directory as a diff. You can inspect it, test it in isolation, and only commit it to the project once you're confident nothing changed in a way that matters to you.</p>\n<p>The discipline this creates is the same as with generated code: you update when you have a reason, not because a tool told you the version is outdated. If the app works, the tests pass, and there's no security advisory - there's a good chance you don't need the update at all. A working application with a slightly older vendored library is a better outcome than a broken application with the latest one. The version number in your <code>package.json</code> is not a measure of code quality. It's just a number.</p>\n<hr />\n<h2 id=\"try-the-upgrade-anyway-just-dont-ship-it\">Try the Upgrade Anyway - Just Don't Ship It</h2>\n<p>The discipline of updating only when you have a reason is sound, but it has a blind spot: the longer you hold a version, the less you know about what upgrading it would cost. A security advisory doesn't care about your maintenance schedule, and neither does the compatibility break that lands in version X+3 while you were content on version X. When those moments arrive, the last thing you want is to be discovering the migration cost under pressure.</p>\n<p>What we do instead, every few months on the libraries and tools that matter to the project, is to run the upgrade locally with no intention of shipping it. Pull the new version, run the tests, read the diff, and look at what changed - not to adopt the change, but to understand the distance between where you are and where you would need to be. If the upgrade is clean, you file that away and move on, knowing that when you do need it the path is clear. If the upgrade reveals something significant - a changed API, a broken test, a pattern the new version handles differently - you know that too, and you know it now, before you are in a situation where you have no choice but to fix it immediately.</p>\n<p>The upgrade gets discarded. The knowledge doesn't. You close the branch, delete the stash, and the application stays exactly as it was - except that you now have a mental model of the upgrade surface that you didn't have before. When the real reason eventually arrives, and it will, you are not discovering the work for the first time. You are executing a plan you already made.</p>\n<p>This is not a formal process and doesn't need to be. An hour every quarter, a local branch that never gets pushed, and the habit of actually reading what changed rather than just checking whether the tests still pass. The libraries we vendor are few enough that this stays cheap. And the cost of not doing it - of treating each version as frozen until forced otherwise - is that you eventually pay for all that accumulated distance at once, in an environment where you can least afford to.</p>\n<hr />\n<h2 id=\"deploy-without-a-registry\">Deploy Without a Registry</h2>\n<p>There is a practical consequence of zero runtime dependencies that doesn't get discussed much because it sounds boring: your deployment cannot fail because of someone else's infrastructure.</p>\n<p>When a project requires <code>npm install</code> at deploy time, it requires npm to be reachable. It requires every package in the tree to still exist at the version specified in the lockfile. It requires the network between your server and the registry to be functioning. These things are usually true, and then occasionally they aren't - at exactly the wrong time. A package gets unpublished. The registry has an outage. A corporate firewall blocks outbound connections to external package registries. <code>npm install</code> hangs or fails, and your deploy stops.</p>\n<p>A project that deploys with <code>git pull</code> and a process restart has none of these failure modes. The repository is the complete truth of what runs. If the repository is accessible, the deploy works. This is not a small thing for the kind of team this is aimed at - <a href=\"/blog/no-deps-no-build\">as covered in more detail in the post on zero-dependency builds</a>, the simpler the deploy, the smaller the blast radius when something goes wrong at an inconvenient hour.</p>\n<p>It also makes Reepolee usable in environments where outbound connections are restricted or prohibited entirely. Schools, hospitals, government agencies, and enterprise environments frequently operate networks where a server cannot reach external package registries at deploy time - either by policy, by firewall, or by design. For these contexts, a framework that requires <code>npm install</code> to function is simply not deployable without additional infrastructure work. A framework that needs only <code>git</code> and a running Bun binary deploys cleanly in the same environment, with no special configuration. <a href=\"/blog/on-premise-or-your-own-vps\">If you're running on your own VPS or on-premise</a>, this matters more than it might seem.</p>\n<hr />\n<h2 id=\"the-honest-trade-off\">The Honest Trade-off</h2>\n<p>None of this is free. A library that updates automatically means you get bug fixes and security patches without any effort on your end. A generator means you have to pull updates deliberately and review what changed. That is real work, and it is worth naming honestly rather than pretending the generator pattern is strictly better in every dimension.</p>\n<p>The trade-off is: automatic updates versus visible change. Implicit behavior versus owned code. Ecosystem velocity versus local understanding.</p>\n<p>For the kind of project Reepolee is built for - small to mid-sized applications, small teams, long maintenance horizons, deployments that need to be understood by whoever is on call - local understanding wins. An application that one person can fully reason about, debug without leaving the repository, and trust because they have read it, is more valuable than one that is always current but always partially opaque.</p>\n<p>The generator pattern is a choice about where trust should live. Not in the registry. Not in someone else's release schedule. In your own repository, in files you have read, in code you own in a way that means something.</p>\n",
      "date_published": "2026-06-23T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/the-reepolee-approach/",
      "url": "https://www.reepolee.com/engineering-notes/the-reepolee-approach/",
      "title": "The Reepolee Approach",
      "summary": "An introduction to the Reepolee philosophy: on-premise infrastructure, zero-dependency applications, code generation over AI, SQL over ORMs, and a commitment to simplicity that compounds over decades - for freelancers, solo devs, small agencies, and informal groups who carry the maintenance cost.",
      "content_html": "<p>There is a way of thinking about software that I have been circling for years now, refining it with every project, every mistake, every late-night debugging session where I realized the tool I reached for had costs I had not accounted for at the start. The posts on this blog cover a range of topics - databases, infrastructure, tooling, team dynamics, architecture - but they are not separate subjects. They are facets of a single way of working, and this post is where I try to name that way and explain what holds it together.</p>\n<hr />\n<h2 id=\"on-the-consensus-we-inherited\">On the Consensus We Inherited</h2>\n<p>The software industry has a way of settling on answers without anyone quite deciding that those are the answers. Not formally, not through any process of collective evaluation, but through the slow accumulation of examples and recommendations and what the people around you seem to agree on until the thing that everyone does becomes the thing that everyone assumes is right. Use the cloud, reach for an ORM, adopt a framework with a build pipeline, support every browser that anyone might be using, move fast and break things.</p>\n<p>Each of these arrived through a reasonable process applied to a reasonable context. The cloud made sense when managing your own hardware became a distraction from building the product. ORMs reduced repetitive SQL when the team was large enough that consistent patterns mattered more than understanding every query. Build tools let you ship modern code before browsers had caught up to the specification. Browser compatibility was the right answer when your audience was the entire internet. Moving fast was survival when the runway was measured in months and the product had not yet found its place.</p>\n<p>The difficulty is not that these answers are wrong. It is that they have drifted outward from the contexts where they were genuinely the best choice and settled into the assumption that they are always the best choice, and a very large category of software has been shaped by that drift without anyone examining whether it fits.</p>\n<p>I work on applications that do not live at internet scale. Membership systems for professional associations. Booking tools for community centers. Training records for certification bodies. Scheduling systems for veterinary practices. Internal tools for teams of twenty people. These applications have tens or hundreds of users, not millions. They run on a single machine. They are maintained by one developer, sometimes part-time, sometimes a volunteer.</p>\n<p>The tooling and conventions that evolved for products with tens of millions of users and engineering teams of fifty people have been applied to these contexts as if the difference in scale did not require a difference in approach. The Reepolee approach is what happened when I stopped accepting those defaults and started asking what would change if I chose the tools and patterns to fit the actual problem rather than the one the industry was organized around solving.</p>\n<hr />\n<h2 id=\"the-commitments\">The Commitments</h2>\n<p>Let me walk through the ideas that hold this together. They are not a complete philosophy of software development. They are the ones that emerged from real projects, real failures, and real maintenance over years, and they are the ones that distinguish this way of working from what the broader industry currently recommends.</p>\n<h3 id=\"where-the-software-lives\">Where the Software Lives</h3>\n<p>The first decision that changes everything else is where the application runs. And the assumption that it should run on someone else's infrastructure, paid for by the month, scaled by someone else's decisions, is an assumption worth questioning if you have not questioned it in a while.</p>\n<p>For the kind of applications I am describing - bounded audiences, predictable workloads, data that belongs to an organization and no one else - a five-euro VPS or a machine in a building handles the load without breaking a sweat. The cost is predictable to the cent. The data stays where the organization decided it should stay, under its own access controls, subject to its own backup policy, governed by the laws of the jurisdiction the organization chose rather than whatever data center region the cheapest cloud tier happened to provision in. When the internet goes down - and it does, regularly, for reasons that have nothing to do with how well the application was built - the application continues to work for everyone on the local network.</p>\n<p>There was a time when running your own server required network administration skills that put it out of reach for small organizations. That time has passed. Tunneling tools have changed the equation completely: a lightweight agent running on the server maintains an outbound connection to an edge node, handles SSL termination, HTTP/3, caching, DDoS protection, and exposes the application to the internet without opening a single inbound port. The server is not directly reachable from the internet. The firewall stays simple. The setup takes an afternoon.</p>\n<p>Linux's systemd handles the hard parts of keeping a process alive. A service definition that says run this command as this user, restart it if it exits, start it when the machine boots - that is fourteen lines of configuration. Database backups are a script and a cron job. Log rotation, health checks, deploy hooks - all of it is solved at the level of shell scripts and standard Linux tooling that has been stable for decades. Reepolee ships a service definition and installs it with a single command. That is not ops engineering. It is a developer who spent an afternoon reading about systemd.</p>\n<p>And the privacy implications run through all of this whether you name them or not. When the data lives on a machine you control, behind a door you lock, on a network you manage, the question of who has access to it has a simple answer. The compliance conversations become straightforward. The audit trail is short. GDPR, CCPA, LGPD - the frameworks keep coming, and the cleanest path through all of them is knowing exactly where the data is and who can touch it.</p>\n<p><a href=\"/blog/on-premise-or-your-own-vps/\"><em>On-Premise, or at Least Your Own VPS</em></a> covers the full case: the cost comparison, the control argument, the regulatory landscape, what happens when the fiber gets cut.</p>\n<h3 id=\"removing-the-build-step\">Removing the Build Step</h3>\n<p>A build pipeline is infrastructure. It requires configuration, maintenance, debugging, and upgrades. It sits between you and the running code when something goes wrong, and when something goes wrong is exactly when you least want something between you and the running code. For a small team maintaining an application that serves a few hundred users, the value of that infrastructure rarely exceeds its cost.</p>\n<p>Bun changed this more than I think the industry has fully absorbed yet. It runs TypeScript directly. No compilation step. No bundler configuration to debug. No fifteen-minute cold starts in CI while the build tool decides what it needs to do. The database client, the HTTP server, file I/O, WebSockets, password hashing, S3 and blob storage - all of it ships in a single binary. The things that used to require Express and a database driver and a session library and a file upload handler and a WebSocket library are simply there, part of the runtime you already have.</p>\n<p>The result is an application that deploys with <code>git pull</code> and restarts with one command. The running server has zero npm packages. Third-party libraries that the application genuinely needs are vendored - downloaded once, inspected, committed to the repository, served directly by the application. No <code>npm install</code> pulls them from a registry at deploy time. No CDN serves them from somewhere else. They are files in the repo, stable, auditable, and present whether or not the registry is up.</p>\n<p>This is not minimalism posed as a virtue. It is the elimination of an entire category of failure modes from the deployment path. Every dependency you do not have is a dependency that cannot have a vulnerability published against it, cannot be abandoned by its maintainer, cannot introduce a breaking change in a minor version bump, and cannot be compromised in a supply chain attack that propagates through the tree silently.</p>\n<p><a href=\"/blog/no-deps-no-build/\"><em>No Deps, No Build</em></a> goes deeper into Bun as an application platform, the economics of dependency management, and why vendoring changes the security posture of a small application more than most developers realize.</p>\n<h3 id=\"what-the-database-speaks\">What the Database Speaks</h3>\n<p>There is a tendency in our part of the industry to treat the database as something to be managed through a layer of indirection, as if direct engagement with it is a legacy practice that modern tooling has made unnecessary. I have watched this tendency cause problems for years, and it is worth being direct about why.</p>\n<p>The database has its own language, its own performance model, its own semantics for consistency and isolation. Writing a query is not like writing application code - it is set-based rather than record-based, declarative rather than imperative, and the execution plan the database chooses for your query can make the difference between a page that renders in ten milliseconds and one that renders in ten seconds. An ORM does not make these things go away. It makes them invisible, which is a worse outcome, because invisible problems do not get solved.</p>\n<p>SQL is not a difficult language. The syntax is small. The concepts - SELECT, JOIN, GROUP BY, transactions - map directly to things you are already doing, expressed differently. What SQL requires is a shift in thinking, and that shift is worth making because the database is one of the most important systems in most applications. A developer who can read an execution plan, write a JOIN instead of N+1 queries, open a transaction explicitly when atomicity matters, and understand what an index covers versus what it does not - that developer controls their data layer rather than being controlled by it. When you cannot read the execution plan yourself, the debugging hours add up fast, and those hours come out of your pocket on a fixed-price project.</p>\n<p>Reepolee generates the SQL directly. The query is what runs. When it is slow, you look at the query. When it does the wrong thing, you read the query. When you need to add a condition, you add it to the query. There is nothing between you and the database but the driver, and that lack of indirection is the feature.</p>\n<p><a href=\"/blog/the-database-is-not-a-javascript-object/\"><em>The Database Is Not a JavaScript Object</em></a> makes the full case for SQL fluency. And <a href=\"/blog/db-is-permanent/\"><em>Your Database Is Probably Permanent</em></a> extends the argument further: once you have committed to a database and it has accumulated years of production history, the idea that you will switch it out for another one is mostly a comforting fiction. Use what it offers - procedures, views, triggers, native functions - rather than withholding those capabilities to preserve a portability option you will almost certainly never exercise.</p>\n<h3 id=\"generating-what-can-be-generated\">Generating What Can Be Generated</h3>\n<p>Large language models are extraordinary tools. I use them daily. They are exceptional at reasoning through novel problems, synthesizing context, understanding what someone is asking for and routing it to the right place. They are the wrong tool for generating routine business application code that has a correct and stable answer.</p>\n<p>The reason is structural. An LLM produces different output from the same input depending on context, phrasing, time of day, and factors that are hard to characterize fully. The result across a codebase built primarily through AI generation is inconsistency: subtle variations in patterns that should be identical, bugs that appear in slightly different forms across dozens of files, a gradual drift away from the conventions the team agreed on because the model was never given those conventions in a way it could apply deterministically.</p>\n<p>A generator does not have this problem. It reads the schema - the actual database schema with your column names, your types, your foreign keys, your constraints - and from that single source of truth it produces, consistently and repeatably, the artifacts that schema implies. Routes, SQL queries, form templates, validation schemas, type definitions, translation keys. Same input, same output, every time. A bug in the generated pattern is fixed in one place and every resource inherits the correction.</p>\n<p>The right use of an LLM in this context is to help build the generator - to encode your domain knowledge into a tool that then produces correct code forever, without paying per-token for every output. Use the model for what it is good at: understanding a description of what you need, reasoning about the context, and routing the request to the generator. Let the generator do what it is good at: producing deterministic, auditable output from a known-good pattern.</p>\n<p><a href=\"/blog/generators-not-llms-for-everything/\"><em>Stop Calling the LLM for Things That Don't Need Thinking</em></a> develops this argument in detail, including the role of MCPs as the bridge between the LLM and the generator. <a href=\"/blog/quality-does-not-matter-until-it-does/\"><em>Quality Doesn't Matter Until It Does</em></a> explains why consistency is what business software actually buys and why the failure modes are different when correctness matters for transactions rather than engagement.</p>\n<h3 id=\"organizing-by-what-the-software-does\">Organizing by What the Software Does</h3>\n<p>There is a reflex in web development to organize code by what it is technically. Models in one folder, views in another, controllers in a third, routes somewhere else entirely. It made sense as a teaching tool for explaining the architecture of a web application. As a folder structure to work in day after day, it creates a cognitive tax that compounds with every file added and every developer who joins the project.</p>\n<p>When you sit down to work on a feature - users, invoices, orders - the unit of your attention is the feature, not the layer. All the code you need to understand and change that feature should be reachable without leaving its context. When the users feature lives in one folder - the route, the SQL, the template, the validation, the translations - opening that folder is opening the feature. You do not need a map. The file system is a map of the domain, and the domain is what you are thinking about when you are working.</p>\n<p>Deleting a feature becomes deleting a folder, and the feature is gone completely by construction. A pull request for a new feature touches one part of the tree, readable in sequence, reviewable without a mental map of the project. A developer who has been on the project for two days can understand a feature by looking at one folder.</p>\n<p>The same instinct applies inside the template. The <code>.ree</code> format keeps what the browser needs - markup, styles, behavior - in one file, readable top to bottom, self-contained. Server-side code lives right next to it in the same folder, close enough to understand in a single directory listing, but separate because the concerns genuinely are different.</p>\n<p><a href=\"/blog/mvc-separated-the-wrong-things/\"><em>MVC Separated the Wrong Things</em></a> explores feature-per-folder architecture, single-file templates, Tailwind's elimination of CSS indirection, and the principle of code proximity in more depth.</p>\n<h3 id=\"simplicity-compounds\">Simplicity Compounds</h3>\n<p>I have spent enough years maintaining software to develop a deep respect for the things I did not add. Every layer of complexity in a system is a layer that can fail, a layer someone has to understand, a layer between you and the running code when something goes wrong at the worst possible moment. This is not an argument against all complexity - some of it is necessary and genuinely valuable. It is an argument for making every layer earn its place.</p>\n<p>Default settings are not laziness. They are the configuration the tool's authors designed, tested, and optimized through thousands of hours of use. The developers who have customized everything sometimes struggle most when they sit down at a machine that does not have their configuration. The portability of your skill with the defaults is worth more than the comfort of your customized setup, and worth thinking about when you reach for the configuration file.</p>\n<p>Snake_case from the database through to TypeScript is not a stylistic preference. It is a visual marker that tells you at a glance which values came from your domain and which came from libraries or external APIs. It eliminates the transformation layer between what the database returns and what your code uses. It makes your naming conventions searchable with grep. The underscore tells you this is yours.</p>\n<p>Failing loudly when an environment variable is missing is not pessimism. It is the fastest possible signal that something is wrong, delivered before any data has been touched, any email has been sent, any invoice has been generated. The application should refuse to start. It should say what it needs and stop. The developer who sees that message fixes it in thirty seconds and moves on.</p>\n<p>Light and dark mode - both, with system preference as the default and a per-site toggle stored in a cookie so the choice survives visits and the theme is set before the first byte renders - is not an extravagance. It is thirty lines of code and two CSS custom property values, and it respects what the user told their operating system while still letting them make a different choice for this specific site.</p>\n<p>Each of these is small. Together they accumulate into a codebase that is simpler than it would have been, more maintainable than it would have been, and less likely to surprise the person who has to debug it at 2am.</p>\n<p><a href=\"/blog/default-is-a-feature/\"><em>Default Is a Feature</em></a> explores the cost of customization and the portability of defaults. <a href=\"/blog/fail-loudly-env-vars/\"><em>Fail Loudly Env Vars</em></a> makes the case for crash-early validation. <a href=\"/blog/name-your-columns-like-you-mean-it/\"><em>Name Your Columns Like You Mean It</em></a> establishes the naming conventions that make a schema readable. <a href=\"/blog/light-dark-do-both/\"><em>Light, Dark, Do Both</em></a> shows the two-layer pattern for theme support.</p>\n<hr />\n<h2 id=\"what-this-is-and-what-it-is-not\">What This Is and What It Is Not</h2>\n<p>Let me be clear about the boundaries, because every approach has them and pretending otherwise helps no one.</p>\n<p>This is not a rejection of cloud infrastructure. Cloud services are clearly the better fit for products that serve the global internet, need to scale unpredictably across regions, or benefit from managed services that would require a dedicated operations team to replicate. The argument is about defaults and context - about asking the question honestly rather than accepting the assumption.</p>\n<p>The same goes for the tools that make modern development faster. I use LLMs daily - to help construct generators, to reason through novel problems, to synthesize the context of a codebase I have not touched in six months. The question is always where their probabilistic nature adds value and where it subtracts it.</p>\n<p>And this is not nostalgia for an earlier era of computing. Bun, modern CSS, native browser APIs, deterministic code generation, tunneling for on-premise deployment - this approach leans into genuinely new capabilities that did not exist five years ago. The difference is in which capabilities are chosen and why. A full-featured framework wrapping an internal tool with three routes is overhead that never earns its keep over the life of the project, but that is not the framework's fault. It is a mismatch between the tool and the problem. The problem is what should drive the choice.</p>\n<hr />\n<h2 id=\"the-audience\">The Audience</h2>\n<p>Let me name directly who this is written for, because it matters for how you read everything that follows.</p>\n<p>You are a freelancer. A solo developer. A small agency of two or three people. An informal group of developers who work together on projects because you share the same convictions about how software should be built. You are the one who gets the call when something breaks, who carries the maintenance cost in your calendar, who has to explain to a client why the monthly hosting bill went up again. You care about simplicity not because it is aesthetically pleasing but because complexity is time you do not have and money you are not being paid for.</p>\n<p>The organizations you build for - trade unions, housing cooperatives, sports clubs, alumni associations, municipal offices, small clinics, volunteer networks - need software that works, that costs what they expect it to cost, that keeps their data where they decided it should stay. They do not have an engineering team. They have you. And you have a finite amount of attention to spread across their projects.</p>\n<p>For the organizations you serve, the honest comparison is not this approach versus a modern cloud deployment with CI/CD and container orchestration. It is this approach versus the spreadsheet it is replacing, or the off-the-shelf system that costs ten times as much and does half of what is needed, or the volunteer-built solution that has been held together with tape for three years and is finally breaking. Against that comparison, an application that runs on a five-euro VPS, deploys with a git pull, keeps data on hardware the organization controls, and asks you to think about it for an afternoon once a quarter is not a compromise. It is the right tool - for them, and for you.</p>\n<hr />\n<h2 id=\"the-bet\">The Bet</h2>\n<p>Reepolee is organized around the conviction that code generation from schema, Bun as the platform, SQL as a skill rather than something to be abstracted away, feature-per-folder organization, on-premise or VPS deployment, and zero runtime dependencies form a strong foundation for a specific and very common category of software that the industry consensus has been underserving for years.</p>\n<p>Some of these commitments will look obvious in five years. One or two might not hold up. That has always been the shape of this kind of work. The alternative - waiting for the consensus to validate each choice before building, reaching for what everyone else has already approved, staying inside the boundary of the well-documented and the safe - produces software that is fine and forgettable in equal measure.</p>\n<p>The rest of this blog works through the details. Each post examines one piece of the puzzle - the database, the deployment, the tooling, the team, the architecture - and lays out why this way of thinking about development and long-term operations produces software that works correctly and keeps working.</p>\n<blockquote>\n<p>The developer who spends a decade maintaining something understands complexity differently than the developer who spent a sprint building it.</p>\n</blockquote>\n",
      "date_published": "2026-06-21T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/default-is-a-feature/",
      "url": "https://www.reepolee.com/engineering-notes/default-is-a-feature/",
      "title": "Default Is a Feature",
      "summary": "On keeping tools at their defaults, the portability of skills over config, why TUIs are nostalgia dressed in Rust, and what native GUI tools are quietly getting right.",
      "content_html": "<p>I want to be upfront about what this post is. It is not a manifesto. It is not a recommendation. It is a confession - a set of observations about what has worked for me over a long time, offered honestly, with full awareness that reasonable people land somewhere different.</p>\n<p>With that said: I think we are doing this wrong, and I've thought so for a while.</p>\n<hr />\n<h2 id=\"the-terminal-as-identity\">The Terminal as Identity</h2>\n<p>Somewhere along the way, the developer terminal became a statement. Custom prompts with git branch indicators and battery levels and exit codes rendered in custom glyphs. Themes that require a specific font or the icons become boxes. Forty shell aliases. A <code>.zshrc</code> that takes fifteen minutes to reconstruct on a new machine and represents years of accumulated tweaks that nobody else understands.</p>\n<p>I've had versions of all of this. I still have a prompt customization tool configured, if I'm being completely honest - the prompt, the git indicators, the whole thing. At various points in my career I've spent real hours tuning my environment, and I understand the appeal - it feels like craft, it signals membership in a community, and there is genuine pleasure in a well-configured tool. I'm not writing this from a position of purity. I'm writing it from a position of recognizing the pattern for what it is.</p>\n<p>But here is what I've noticed: the developers with the most elaborate setups are sometimes the least portable. Sit them at a fresh machine - a client's laptop, a pair programming session, a new employer's hardware - and the first hour goes to configuration. Not to work. To making the environment feel like home before anything useful can happen.</p>\n<p>That dependency is a cost, and it's a cost I've chosen to stop paying.</p>\n<hr />\n<h2 id=\"default-is-not-laziness\">Default Is Not Laziness</h2>\n<p>The stock terminal. A mainstream editor with a handful of extensions - the ones that add capability, not the ones that change how things look. No custom theme. Default keybindings. The standard tools the ecosystem ships with, used as they were designed to be used.</p>\n<p>This isn't laziness and it isn't lack of curiosity. It's a deliberate choice about where to spend the learning budget. Every hour spent mastering a custom setup is an hour not spent mastering the underlying tools. And the underlying tools transfer. The custom setup doesn't.</p>\n<p>A developer who knows a mainstream editor deeply - the debugger, the built-in git integration, the terminal, the settings sync, the refactoring tools - can sit at any machine in the world with that editor installed and work at full capacity within minutes. That developer's skills are portable in a way that no amount of carefully tuned configuration can replicate.</p>\n<p>The &quot;any machine&quot; test is the one I come back to. If you had to work tomorrow on a fresh install with only the tools that ship by default, how long before you're productive? That gap - between where you are now and where you could be - is the cost of the configuration you've accumulated.</p>\n<hr />\n<h2 id=\"tuis-are-nostalgia-in-a-rust-crate\">TUIs Are Nostalgia in a Rust Crate</h2>\n<p>I want to say something that will upset people: the TUI renaissance is going backwards.</p>\n<p>The git clients, the cluster managers, the beautiful terminal dashboards, the Rust-powered everything with box-drawing characters and vim keybindings and carefully crafted color schemes - this is genuinely impressive engineering. The developers who built these tools are talented and the tools themselves are often excellent. I am not saying otherwise.</p>\n<p>What I am saying is that we have GPUs. We have native UI frameworks. We have high-DPI displays and smooth animation and the full capability of modern operating systems available to any application that wants to use it. And a significant portion of the developer tooling community has decided that the correct response to all of this is to draw boxes with ASCII characters and call it modern.</p>\n<blockquote>\n<p>It isn't modern. It's 1987 with better syntax highlighting.</p>\n</blockquote>\n<p>The terminal made sense when it was the only interface available. Today it's a choice - and for local development on a machine with a full operating system, it is often the wrong choice dressed up as craft.</p>\n<p>The server argument is real and I'll concede it immediately: when you're SSH'd into a production machine at 2am, you're at defaults, and knowing the terminal deeply matters. But that's the server. On your local machine, with a trackpad and a Retina display and 64GB of RAM, reaching for a TUI because it feels like serious engineering is a different thing.</p>\n<hr />\n<h2 id=\"plugins-are-technical-debt-you-dont-track\">Plugins Are Technical Debt You Don't Track</h2>\n<p>Let me be precise here, because there's a version of this argument that goes too far. Modern editors sync your extensions across devices automatically, so the machine-switching problem is largely solved - what syncs with you is the list you've built, for better or worse. Extensions that earn their place are fine - more than fine. A bracket highlighter is a genuine quality-of-life improvement that costs almost nothing and saves real cognitive effort every day. Language support, a spell checker, a git diff viewer - these add capability the editor doesn't ship with and the case for them is obvious. I use them. It would be nuts not to.</p>\n<p>The problem is a different category entirely: the editor you switch to because one feature went viral on social media. A new tool appears, it does one impressive thing, it gets screenshotted and shared and talked about for two weeks, and a certain kind of developer installs it immediately and reorganizes their entire workflow around it. Three months later the hype has moved on, the tool has a breaking update, and the workflow is half-dismantled.</p>\n<p>A related one that has always puzzled me: the obsession with editor startup time. I open my editor at nine in the morning and close it at ten at night. Whether it takes 0.2 or 2 seconds to start is so far outside the list of things I care about that benchmarking it feels like measuring how fast a chair unfolds. The tools that lead with startup time as a selling point are usually the ones that don't have enough of the other things to talk about.</p>\n<p>This is the menace. Not extensions that serve real needs - extensions and tools chased for their social signal rather than their utility.</p>\n<blockquote>\n<p>The test is simple: does this solve a problem I actually have, or does it solve a problem I didn't know I had until someone on the internet told me I did?</p>\n</blockquote>\n<p>The first category belongs in the editor. The second belongs in a bookmark you never open again.</p>\n<hr />\n<h2 id=\"something-new-is-forming\">Something New Is Forming</h2>\n<p>Here is where the confession gets more interesting, because I don't think the answer is &quot;go back to the terminal and suffer virtuously.&quot; Something genuinely new is forming, and it's neither the old GUI nor the TUI revival.</p>\n<p>Tools like Claude Code and Codex are not terminal tools pretending to be modern. They're not GUI applications pretending to be terminals. They're a different interface category for a different kind of work - AI-native, context-aware, operating at a level of abstraction that makes the question of &quot;terminal vs GUI&quot; feel slightly beside the point.</p>\n<p>I find it meaningful that these tools are native applications rather than terminal wrappers. Not because native is inherently better, but because the people building them chose to use the full capability of the platform rather than retreating to the lowest common denominator. That choice signals something about where serious tooling is going - and it isn't toward more box-drawing characters.</p>\n<hr />\n<h2 id=\"what-i-actually-do\">What I Actually Do</h2>\n<p>For what it's worth: I work daily across Windows, macOS, and Linux. The native terminal on each of them is fine. More than fine - each one does what a terminal needs to do, and knowing that means I've never spent a day blocked because I was on the wrong OS. A mainstream editor with default settings and a short extension list I could rebuild in ten minutes. The standard CLI tools that ship with the OS or install with a single command. A prompt customization tool that shows me the git branch, the project context, the things I'd otherwise have to ask the terminal - a quality of life addition I could live without but choose not to. No theme beyond that. No aliases beyond the handful I've had for so long I can no longer imagine which ones they are.</p>\n<p>I can work on any machine. I can pair with anyone without apologizing for my setup. When something breaks, I know where to look because I know what I've changed - which is almost nothing. When a new colleague joins, I can describe my environment in two sentences.</p>\n<p>This is not the most impressive setup. It is not the one that gets admired at conferences or screenshotted for social media. It is the one that still works the same way after three years, after five machine changes, after a hundred &quot;let me just SSH in quickly&quot; moments. And when a colleague is stuck on something, I can help them directly - because we're both looking at the same tools, the same defaults, the same interface. No &quot;oh, I have a plugin for that&quot; that they don't have.</p>\n<p>The best tool is the one you don't have to think about. Default settings are how a tool's authors intended it to be used, with the full weight of their design decisions behind them. Overriding those defaults is sometimes necessary. More often, it's a way of spending energy on the environment instead of on the work.</p>\n<p>That's the observation. Take it or leave it.</p>\n",
      "date_published": "2026-06-20T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/generators-not-llms-for-everything/",
      "url": "https://www.reepolee.com/engineering-notes/generators-not-llms-for-everything/",
      "title": "Not Everything Needs an LLM",
      "summary": "Why smart teams use LLMs to build generators, not to replace them - and how encoding domain knowledge once, deterministically, beats paying an API per token forever.",
      "content_html": "<p>Let me say something that might sound odd coming from someone who uses AI tools every day: most of the work I see teams sending to LLMs should not be going there. Not because LLMs are bad at it. Because there's a better tool for it, and the better tool is free, deterministic, and can encode your domain expertise instead of guessing at it.</p>\n<p>I'm talking about generators. And the confusion between what a generator does and what an LLM does is quietly costing teams money, consistency, and - most expensively - trust in their own output.</p>\n<hr />\n<h2 id=\"the-bill-youre-not-reading-carefully-enough\">The Bill You're Not Reading Carefully Enough</h2>\n<p>An LLM API call costs money. Not a lot per call, usually, but software doesn't make one call. It makes thousands, tens of thousands, millions - and those costs compound in a way that looks manageable on the first invoice and alarming on the twelfth.</p>\n<p>But the dollar cost is actually the smaller problem. The bigger problem is that every LLM call is a roll of the dice. Not a wild roll - the model is sophisticated, the probability distribution is well-shaped - but still a roll. Ask it to generate a route handler for a new resource today and you get one thing. Ask it tomorrow, with a slightly different phrasing, and you get something slightly different. Ask five developers on your team to prompt it for the same thing and you'll get five variations that are all &quot;correct&quot; in the way that a plausible median is correct, none of which necessarily match how <em>your</em> system does it.</p>\n<blockquote>\n<p>The inconsistency isn't only the model's fault. A large part of it is us.</p>\n</blockquote>\n<p>We prompt differently every time - different context, different level of detail, different day, different mood. We don't give the model our validation conventions, our error-handling patterns, our column naming rules, our transaction requirements. We expect it to infer them from a few lines of description and the general shape of its training data, and then we're surprised when the output needs three rounds of editing before it fits the codebase.</p>\n<p>The model is doing its job. We're not doing ours, because &quot;our job&quot; in this context is too expensive and too inconsistent to do well at scale.</p>\n<hr />\n<h2 id=\"what-a-generator-actually-does\">What a Generator Actually Does</h2>\n<p>A generator doesn't guess. It reads your schema - your actual database schema, with your column names, your types, your foreign keys, your constraints - and from that single source of truth it produces, deterministically and repeatably, the artifacts that schema implies: route handlers, SQL queries, form templates, validation schemas, type definitions, translation keys.</p>\n<p>Same input. Same output. Every time.</p>\n<p>The domain knowledge that an LLM has to re-derive on every call - what this column type means in your application, what this relationship implies about your join patterns, how this kind of form should validate - lives permanently in the generator. You encoded it once. You reviewed it once. You made the decision about how your application handles decimal precision in currency fields, or how it handles soft deletes, or what it does with nullable foreign keys - and that decision lives in the generator forever, applied consistently to every resource it produces.</p>\n<p>When you find a bug in the pattern - a missing transaction boundary, a validation edge case you hadn't considered - you fix it in one place, run the generator again, and every resource it has ever produced gets the corrected behavior. With AI-generated code, the same logical mistake exists in twenty files in slightly different forms, each one requiring manual discovery and individual repair.</p>\n<p>The economics of this are not close. <em>Deterministic generation is dramatically cheaper and more consistent than probabilistic generation for any task with a stable, codifiable structure.</em> And business software is, in large part, made of exactly that kind of task - and if you want to understand what the failure mode looks like when it isn't treated that way, <a href=\"/blog/quality-does-not-matter-until-it-does/\">Quality Doesn't Matter Until It Does</a> goes into it at length.</p>\n<hr />\n<h2 id=\"but-llms-did-the-hard-part\">But LLMs Did the Hard Part</h2>\n<p>Here is the part people miss when they hear &quot;use a generator instead of an LLM&quot;: I'm not suggesting you go back to writing generators by hand from scratch, which is a significant investment. I'm suggesting you use an LLM once - once, carefully, with full context and your actual schema - to help you build the generator that then does the work forever.</p>\n<p>This is a completely different use of the tool, and it's the right one. LLMs are expensive per call but extremely good at reasoning through a novel, complex problem with adequate context. Building a generator is exactly that kind of problem: understanding your domain, your patterns, your conventions, your edge cases, and turning that understanding into a codified, auditable, repeatable process. Do it once. Pay for it once. Let the result compound.</p>\n<p>The distinction matters more than it might look: using an LLM to generate a route handler on demand is like asking a brilliant consultant to draft every email you send. Using an LLM to build a generator is like asking that consultant to write your communication guidelines so your whole team writes consistently from now on - and never having to call them again for the same thing.</p>\n<hr />\n<h2 id=\"the-inconsistency-problem-has-a-human-origin\">The Inconsistency Problem Has a Human Origin</h2>\n<p>There's an uncomfortable truth in any honest post-mortem on AI-generated output quality: a lot of the variability we attribute to the model is actually variability we introduced. The same LLM given the same context produces much more consistent output than the same LLM given different context by five different developers over three months.</p>\n<p>We don't give it the same context. We don't prompt at the same depth. We don't maintain the same conventions in our instructions. We don't inject the same domain constraints. Some of us give it the full schema. Some of us give it a vague description. Some of us give it examples from the codebase. Some of us don't.</p>\n<p>The result is output that looks consistent to someone who hasn't read it carefully, and isn't. This is a genuine engineering problem, not a user-error story - because asking every developer on a team to maintain perfect, identical, comprehensive prompts across every task is not a realistic engineering process. It doesn't scale. It degrades under deadline pressure. It depends on individual discipline in a way that no serious production system should depend on.</p>\n<p>A generator removes the human variability from the loop entirely. The conventions aren't in someone's prompt - they're in the generator's logic, where they can be reviewed, versioned, and trusted.</p>\n<hr />\n<h2 id=\"steering-files-help-up-to-a-point\">Steering Files Help - Up to a Point</h2>\n<p>The industry has noticed the inconsistency problem, and the response has been steering files: <code>CLAUDE.md</code>, <code>AGENTS.md</code>, skills definitions, system prompts checked into the repository. The idea is sound - give the model a stable, shared context so every developer on the team is starting from the same place rather than from whatever they happened to type today.</p>\n<p>And they do help. A well-written <code>CLAUDE.md</code> that describes your project's conventions, your preferred patterns, your naming rules, your testing approach - that genuinely narrows the variance. The model reads it, and the output is measurably more consistent than it would be without it. I use them. I'd recommend them.</p>\n<p>But they are not a catch-all, and mistaking them for one is where teams get into trouble.</p>\n<p>Steering files are still instructions to a probabilistic system. The model reads them and does its best to honor them - but &quot;does its best&quot; is not the same as &quot;always does.&quot; The instructions can conflict with each other. They can be ambiguous in ways that only surface in specific edge cases. They can drift out of sync with the actual codebase as the project evolves, silently, without anyone noticing, because they're a text file rather than an executable specification. And they don't help at all with the parts of consistency that depend not on convention but on correctness - the transaction boundary that needs to be there, the rounding rule that must match the contract, the validation that has to be exactly right.</p>\n<p>There's also a maintenance cost that compounds quietly. A steering file that nobody is actively curating becomes a liability - a set of instructions that partially describe a system that has moved on, pointing the model in directions that are no longer accurate. Keeping it current requires the same kind of discipline that writing good prompts requires, distributed across the whole team, sustained over the life of the project. That is harder than it sounds.</p>\n<blockquote>\n<p>A steering file is a better input to a probabilistic system. A generator is a different system entirely.</p>\n</blockquote>\n<p>The distinction is the kind that matters in production. Use steering files - they help, and the good ones help a lot. But use them for what they're good at: guiding the model on tasks that genuinely require judgment, not for tasks that have a deterministic correct answer your tools should already know.</p>\n<hr />\n<h2 id=\"mcps-are-the-bridge-not-the-replacement\">MCPs Are the Bridge, Not the Replacement</h2>\n<p>There's a third piece to this, and it's where the architecture comes together in a way that actually makes sense.</p>\n<p>Model Context Protocol tools - MCPs - give an LLM direct, structured access to the systems around your application: your database, your file system, your APIs, your documentation. But the most useful thing an MCP can do isn't to let the model generate code directly. It's to let the model call your generator.</p>\n<p>This is the shape that works in practice. The developer describes what they need - a new resource, a new form, a new endpoint - and the LLM understands the intent, reasons about the context, and calls the MCP. The MCP hands off to the generator. The generator produces the correct, consistent, deterministic output it always produces, because that's what generators do. The LLM was useful as an interface, as a reasoning layer, as the thing that understood what was being asked. The actual authorship of the code stayed where it belongs - in a process you reviewed, trust, and can audit.</p>\n<p>This is the distinction worth holding onto: <em>use the LLM to describe what you need, and let every tool at your disposal help you achieve it in a repeatable and correct way.</em> The model is at its best when it's orchestrating - routing intent to the right tool, composing results, reasoning about what should happen next. It's at its worst when it's freehanding code that has a deterministic correct answer your system already knows how to produce.</p>\n<p>The question to ask about any generation task isn't &quot;can the LLM do this?&quot; - it almost certainly can. The question is &quot;does this task have a correct, stable, codifiable answer?&quot; If yes, that answer belongs in a generator, and the LLM's job is to call it.</p>\n<hr />\n<h2 id=\"consistency-is-what-business-actually-buys\">Consistency Is What Business Actually Buys</h2>\n<p>I want to be direct about what's really at stake for teams building software that runs businesses, because the appeal of &quot;just use the LLM for everything&quot; is strong and the counter-argument needs to be concrete.</p>\n<p>Business software's value is not in novelty. It's in reliability. The application that calculates payroll correctly this Friday, next Friday, and every Friday for the next five years is worth infinitely more than the application that gets it right four Fridays out of five. The inventory system that handles concurrent orders consistently under load is worth more than the one that handles them correctly most of the time.</p>\n<p>Consistency is what business actually buys when it buys software. And consistency - the real kind, the kind that survives developer turnover and midnight deployments and the edge case nobody tested - comes from codified, deterministic, auditable processes. Not from a system that produces a good-looking output eighty-five percent of the time and a subtly wrong one the rest.</p>\n<p>We're enormous fans of LLMs. What they can do for reasoning, for synthesis, for working through complex and novel problems, is genuinely transformative. But business doesn't run on brilliance that shows up most of the time. It runs on correct, predictable behavior that can be counted on every time.</p>\n<hr />\n<h2 id=\"the-shape-of-a-sane-approach\">The Shape of a Sane Approach</h2>\n<p>It doesn't have to be all or nothing. The approach that has worked best for us is thinking in layers rather than making either/or choices.</p>\n<p>The LLM is the interface - it understands intent, reasons about context, decides which tool to reach for. The MCP is the integration layer - it connects the model to the real systems around it, including the generator. The generator is the workhorse - it takes a stable, well-understood task and produces correct output every time, because you encoded the right answer into it once and it has been applying that answer faithfully ever since.</p>\n<p>These layers compose. &quot;I need a new resource for customer invoices&quot; goes to the LLM, which understands what that means in the context of your project, calls the MCP, which triggers the generator, which produces the route, the SQL, the form, the validation, and the translation keys - consistently, correctly, for free. The LLM contributed what it's actually good at: understanding a description and routing it to the right tool. The generator contributed what it's actually good at: deterministic, auditable output from a known-good pattern.</p>\n<p><em>Building and improving the generator itself</em> is where you spend a careful, well-contextualized LLM session. Encode your decisions into the generator's logic. Do it once. Pay for it once. Let the result compound.</p>\n<hr />\n<h2 id=\"this-is-what-reepolee-is-for\">This Is What Reepolee Is For</h2>\n<p>Reepolee is a codegen starter built around exactly this philosophy. It introspects your database schema and generates a consistent, full-stack application layer from it - routes, SQL, forms, validation, translations - using patterns you define and review once, applied everywhere without drift.</p>\n<p>It is not an AI product. It does not call an LLM at runtime. It is deterministic by design, which is precisely the point: your schema goes in, correct and consistent code comes out, according to rules your team agreed on, auditable in a way that probabilistic generation never quite is.</p>\n<p>If you're building a business application of small to mid size - not a platform serving a hundred thousand concurrent users, just a real application that needs to work correctly and be maintainable by a small team - Reepolee is built for exactly that context. The goal was always to make the boring, necessary, repetitive parts of that work disappear so that the genuinely interesting work gets all the attention it deserves.</p>\n<p>Use the LLM to build better generators. Use the generators to build consistent software. That's the loop worth investing in.</p>\n",
      "date_published": "2026-06-20T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/light-dark-do-both/",
      "url": "https://www.reepolee.com/engineering-notes/light-dark-do-both/",
      "title": "Light, Dark, Do Both",
      "summary": "On implementing light and dark mode correctly - system preference as the default, per-site user override, and why both are needed.",
      "content_html": "<p>The browser has had <code>prefers-color-scheme</code> for years, it reads the operating system preference and exposes it as a CSS media query, and using it as the default is a good starting point because the user told their OS what they want and that preference deserves to be honored. It is not, however, the complete answer to what the user actually needs on any given site.</p>\n<hr />\n<h2 id=\"the-os-setting-is-a-default-not-a-contract\">The OS Setting Is a Default, Not a Contract</h2>\n<p>A user might run their OS in dark mode because they work in low-light conditions at night, and still prefer a specific application in light mode because the content is easier to read that way - a document editor, a data table, something with dense information where contrast works differently. The OS preference is a reasonable starting point, not a mandate.</p>\n<p>Respecting <code>prefers-color-scheme</code> gets you ninety percent of the way there. The remaining ten percent is giving the user a way to say: for this site, I want the other one. And remembering that choice the next time they come back.</p>\n<p>This is a two-layer model: system preference as the default, per-site user override stored locally. Both layers matter. A site that only reads the OS preference removes the user's ability to make a site-specific decision. A site that ignores the OS preference entirely forces the user to make a choice before they've seen anything.</p>\n<hr />\n<h2 id=\"the-implementation-is-not-complex\">The Implementation Is Not Complex</h2>\n<p>The pattern is straightforward: start with the OS preference via the media query, add a toggle the user can reach without effort - one click, visible but not intrusive - and when they toggle, store the choice in a cookie that travels with every subsequent request, which means the server can read it before the first byte of HTML is sent and set the <code>data-theme</code> attribute on the document root before the page renders. The rest is CSS custom properties: every color in the application references a variable, and the variable changes when the theme changes. No flash of the wrong theme, no client-side correction after the fact, no framework required, because the whole mechanism is a cookie, a server that reads it before rendering, and a CSS custom property system that responds to the attribute.</p>\n<blockquote>\n<p>The system preference is a suggestion. The stored preference is a decision. Treat them differently.</p>\n</blockquote>\n<hr />\n<h2 id=\"why-both-layers-exist\">Why Both Layers Exist</h2>\n<p>There is another reason the per-site override matters that rarely gets said directly. The author uses dark mode, so the dark theme is polished, tested, and visually coherent. The light theme exists because it was expected to, but it was built in an afternoon and hasn't been touched since. Or the reverse.</p>\n<blockquote>\n<p>Most applications only have one theme that was actually finished.</p>\n</blockquote>\n<p>The user who opens the app and finds the alternative theme barely functional is not wrong to want to switch - they are responding to reality. The per-site toggle gives them a way to use the application optimally without changing their OS setting to do it.</p>\n<p>A developer who implements only the media query is saying: I trust the OS setting more than I trust the user to know what they want on this specific site. A developer who implements only a manual toggle is saying: I don't care what the user told their OS. Neither is correct on its own.</p>\n<p>The combination says: I read your system preference, I used it as your starting point, and I gave you a way to change it here without changing it everywhere. That is the respectful behavior, and it takes about thirty lines of code to implement correctly.</p>\n<p>Reepolee generates this pattern by default. The theme toggle is part of the layout, the preference is stored in a cookie and read server-side on every request, and the color system is built on CSS custom properties from the start - which means the application ships with both layers already working, no flash of wrong theme, and the developer's only job is to define the color values for each theme.</p>\n",
      "date_published": "2026-06-20T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/living-on-the-edge/",
      "url": "https://www.reepolee.com/engineering-notes/living-on-the-edge/",
      "title": "Living on the Edge",
      "summary": "On early adoption, one-off software, the kind of problems nobody else wants to tackle, and why code is the advantage that compounds.",
      "content_html": "<p>I have never sold the same software twice, because every application I have built has been tailor-made for a specific problem, a specific organization, a specific set of constraints that made off-the-shelf solutions either too expensive, too generic, or simply absent, and this is not a business model I planned so much as what happens when you keep saying yes to the problems nobody else wants.</p>\n<p>The problems nobody else wants are often the most interesting ones. They are the ones that require something unusual - an architecture that doesn't follow the template, a technology that isn't proven yet, an approach that looks wrong until it works. I have found, over a long career, that new technology is frequently that approach. Not because new is better by definition, but because the right new tool sometimes makes possible what the established ones make awkward or impossible.</p>\n<hr />\n<h2 id=\"before-it-had-a-name\">Before It Had a Name</h2>\n<p>In 2000, just after the industry had spent two years convincing itself the calendar rollover would end civilization and then quietly moved on, I was building an application that could not do full page reloads. The data changed too frequently, the interaction was too tight, the user experience of reloading the entire page for every update was simply not acceptable for what the application needed to do. So I built something that used XMLHttpRequest to send requests in the background and updated only the part of the page that needed to change - encoding and decoding the payloads by hand, running in Internet Explorer because that was the browser.</p>\n<p>A few years later the industry named this pattern AJAX and treated it as a revelation.</p>\n<p>I am not telling that story to claim credit for anything, but because it illustrates the thing I have come to believe about early adoption: the technology existed. The need existed. The name and the community and the blessed pattern came later. The application didn't care about any of that. It needed to work, and the unconventional approach was the one that made it work.</p>\n<hr />\n<h2 id=\"the-dismissal-is-part-of-the-pattern\">The Dismissal Is Part of the Pattern</h2>\n<p>Every technology I have adopted early has been dismissed by someone whose opinion I respected at the time. JavaScript on the server. Asynchronous patterns before they were standardized. Vue when the community was still debating whether it was a serious alternative. Tailwind when the reaction was mostly horror at utility classes in HTML. Svelte before it had the audience it has now. Vite the day it was announced, before most people had finished reading what it was. Code generation over manual scaffolding. Bun before the community decided it was serious. The dismissal is consistent enough that I have come to treat it as a mild signal in the other direction - not a guarantee, but a data point worth noting.</p>\n<p>The people doing the dismissing are not wrong to be cautious. Caution is a reasonable response to instability, to missing documentation, to APIs that change between versions, to a community small enough that your question might not have an answer yet. Those are real costs. The question is whether the benefit outweighs them for the specific problem in front of you - and that question has to be answered honestly, not defaulted to no because the consensus hasn't arrived yet.</p>\n<blockquote>\n<p>The consensus is a lagging indicator. It tells you what was worth learning, after the learning window has mostly closed.</p>\n</blockquote>\n<hr />\n<h2 id=\"code-is-what-compounds\">Code Is What Compounds</h2>\n<p>Writing about a technology and writing code with it are not the same thing, and reading about Bun and building an application server with Bun are not the same thing either. The understanding that comes from actually shipping something with a new tool - hitting the edges, finding the gaps, working around the missing pieces, discovering what it does better than you expected - is an understanding that cannot be acquired any other way and cannot be caught up to quickly once someone has it.</p>\n<p>This is why early adoption is an advantage that compounds. The developer who shipped something on a platform before the mainstream arrived has a year or two of real experience when the mainstream does arrive. They know which patterns hold, which the documentation gets wrong, which things that sound like limitations are actually features once you understand them. That knowledge is not theoretical. It is in the code they wrote, the problems they solved, the instincts they built while everyone else was still skeptical.</p>\n<p>When Bun stabilizes to the point where the majority of the industry considers it the default - and I believe it will - the developers who built on it early will have an advantage that a blog post cannot give you.</p>\n<hr />\n<h2 id=\"this-is-not-about-playing-with-toys\">This Is Not About Playing With Toys</h2>\n<p>Early adoption gets misread as enthusiasm for novelty, as if it were the developer who chases every new release, installs everything, and moves on before anything ships, and that is not what I am describing. The technologies I adopted early were adopted because they solved a real problem I had, or opened a possibility I was looking for, or offered a fundamentally cleaner approach to something I was already doing the hard way. Some of them dissolved, and I moved on, sometimes back to what I was using before, without regret, because the exploration had value regardless of whether the technology survived.</p>\n<p>What it gives you, beyond the technical fluency, is the one thing no amount of reading can replace: you are not surprised. When a client or a partner brings up a technology in a meeting - something they read about, something their team is evaluating, something they are excited or worried about - the answer is already there. Not because I prepared for that meeting, but because I was already there six months ago, writing code with it, finding its edges, and forming an actual opinion that means the conversation is already over before it becomes a gap.</p>\n<blockquote>\n<p>The answer is usually just &quot;Yes, Sir.&quot;</p>\n</blockquote>\n<hr />\n<h2 id=\"the-bun-controversy-is-the-argument-not-against-it\">The Bun Controversy Is the Argument, Not Against It</h2>\n<p>As this is being written, Bun is in the middle of exactly the kind of turbulence that comes with early adoption. Jarred Sumner, who built Bun, is now at Anthropic following the acquisition of Oven in late 2025 - which puts him, in the loosest possible sense, on the same team as the AI writing this post. In May 2026, nearly a million lines of Zig were rewritten in Rust in six days - not by developers, but by AI. Six thousand commits. No human wrote a line. The result shipped with over thirteen thousand unsafe blocks in a language where the main reason you'd choose it is memory safety. Zig's no-AI-contributions policy, formalized just before the rewrite, was part of what forced the switch. The community reaction has been pointed, and some of the criticism is technically fair.</p>\n<p>I am watching it, and I have not abandoned the bet.</p>\n<p>The bet on Bun was never on Zig. It was on the API surface - the native HTTP server, the SQL client, the file I/O, the philosophy of a runtime that ships what an application needs without reaching for packages. Those things have not changed. The tests mostly pass. The unsafe blocks are being audited - Bun's own analysis shows the majority are addressable. The direction of the platform, the reason it was the right foundation for Reepolee, is intact.</p>\n<p>This is what early adoption actually looks like from the inside. Not a smooth ride from promising to proven. A platform you believe in, turbulence you didn't anticipate, and a decision about whether the turbulence changes the fundamental case - and in this instance it doesn't, because the implementation language is not the product, and the product is what the runtime lets you build without ceremony, and that is still there.</p>\n<blockquote>\n<p>Early adoption means staying when it gets uncomfortable, not just arriving before everyone else.</p>\n</blockquote>\n<hr />\n<h2 id=\"what-reepolee-is-a-bet-on\">What Reepolee Is a Bet On</h2>\n<p>Reepolee is built on Bun. Not because Bun is fashionable - it is not yet, particularly - but because the native API surface, the performance, the philosophy of a wide standard library over a fragmented ecosystem, and the clean model of developing for the platform rather than against it, are the right foundation for the kind of applications Reepolee is designed to produce, and that is the genuine frontier bet.</p>\n<p>The rest - code generation, server-side rendering, SQL as a first-class skill - are not bets on new technology. They are bets on old technology that never stopped being correct. SSR has been around for decades. Codegen has been around for decades. SQL has been around for longer than most developers reading this have been alive. The industry spent a few years convinced it had outgrown all of them, reached for client-side rendering and ORMs and manual scaffolding, and is now slowly, quietly, arriving back at the same conclusions that were already settled, and Reepolee did not wait for that return because it just never left.</p>\n<blockquote>\n<p>Simplicity is a feature.</p>\n</blockquote>\n<p>It is not a compromise made by teams too small to do it properly, but a deliberate choice made by developers who understand that every layer of complexity added is a layer that can fail, a layer someone has to maintain, a layer between you and the running code when something goes wrong.</p>\n<p>Some of these bets will look obvious in five years, and one or two of them might not pan out, which has always been the shape of this kind of work. The alternative - waiting for consensus before building, reaching for what everyone else has already validated, staying inside the boundary of the well-documented and the safe - produces software that is fine and forgettable in equal measure.</p>\n<p>I would rather be early and occasionally wrong than always late and never interesting.</p>\n",
      "date_published": "2026-06-20T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/mvc-separated-the-wrong-things/",
      "url": "https://www.reepolee.com/engineering-notes/mvc-separated-the-wrong-things/",
      "title": "MVC Separated the Wrong Things",
      "summary": "On code proximity, feature-per-folder, single-file templates, and why Tailwind classes tell you more about a component than a semantic class name ever will.",
      "content_html": "<p>There is a particular kind of friction that experienced developers stop noticing because they've had it for so long. You're working on a feature - let's say users, something as ordinary as a list, a form, and a delete confirmation - and you have six files open at once. You scroll between them. You CMD+click to follow a call into a controller that lives three directories away from the template it renders. You hold the whole feature in your head because the code doesn't hold it for you; the code is distributed across the project according to what <em>type</em> of thing each file is, not according to which feature it belongs to.</p>\n<p>MVC taught us to organize by technical role. Models here, controllers there, views in their own folder. It was a useful mental model for explaining how a web application works. As a folder structure to actually work in, it creates a cognitive load that compounds with every file you add and every developer who joins the project.</p>\n<hr />\n<h2 id=\"the-unit-was-wrong-from-the-start\">The Unit Was Wrong From the Start</h2>\n<p>When you sit down to work on a feature, the unit of your attention is the feature. Not &quot;the model layer&quot; or &quot;the view layer&quot; - the feature. You are thinking about users, or invoices, or orders. Everything you need to understand, change, and ship that feature should be reachable without leaving its context.</p>\n<p>MVC organizes code by what it <em>is</em>. A feature-per-folder approach organizes code by what it <em>does</em>. The distinction sounds small; in practice it changes everything about how you work on a project day to day.</p>\n<p>When the users feature lives in one folder - the route, the SQL, the template, the validation, the translations - opening that folder is opening the feature. You don't need a map. You don't need to know where the framework puts things. The file system is a map of the domain, and the domain is what you're thinking about when you're working.</p>\n<hr />\n<h2 id=\"swiping-between-files-is-not-a-workflow\">Swiping Between Files Is Not a Workflow</h2>\n<p>The physical experience of working in a separated architecture is worth naming, because we normalize it so quickly that it disappears from our awareness. Three files open side by side. Swiping between them to follow a single request. Jumping to a definition that lives in a different directory tree. Holding the connection between them in working memory because nothing in the structure makes it explicit.</p>\n<p>This is not a minor inconvenience. It is a continuous tax on attention, and attention is the actual resource being spent when software is written. Every jump between files is an interruption. Every time you have to reconstruct &quot;what does this controller do with what the model returns and how does the view consume it,&quot; you're spending time that could go toward the actual problem.</p>\n<p>Feature-per-folder doesn't eliminate complexity. It localizes it. The complexity of the users feature lives in the users folder. It doesn't leak into a shared controller file that also handles ten other things, or a routes file that has grown to six hundred lines, or a model layer that everyone touches and nobody fully owns.</p>\n<hr />\n<h2 id=\"single-file-templates-for-the-client-side\">Single-File Templates for the Client Side</h2>\n<p>The same logic applies inside the template, with one clarification worth making: client-side concerns and server-side concerns are different things, and they don't belong in the same file just because they belong to the same feature.</p>\n<p>What does belong in the same file is everything the browser needs to render the component - the HTML structure, the CSS that styles it, the JavaScript that makes it interactive. A template that splits those three across separate files asks you to hold three open files in your head just to understand what one element does. The stylesheet is over there. The script is somewhere else. The markup references both by name, and you follow the references.</p>\n<p>The <code>.ree</code> format keeps the client-side layer together: markup, styles, and behavior in one file, readable top to bottom, self-contained for everything the browser cares about. The server-side code - the route, the SQL, the validation - lives right next to it in the same folder, close enough to understand in a single directory listing, but separate because the concerns genuinely are separate.</p>\n<p>This is the distinction worth holding: not &quot;everything in one file&quot; but &quot;the right things in the same place.&quot; Client-side code is one thing. Server-side code is another. The feature folder is what holds them together without conflating them.</p>\n<hr />\n<h2 id=\"keep-the-helper-where-it-lives\">Keep the Helper Where It Lives</h2>\n<p>The same instinct that scatters code across layer folders also has a way of producing a <code>utils.js</code> file - or <code>helpers.js</code>, or <code>lib/common.ts</code>, whatever the project calls it - that grows quietly in the background, accumulating functions that were written for one place and moved to the shared file preemptively, just in case.</p>\n<p>The right rule is simpler: write the function where you need it. If a second part of the codebase needs the same function, that's the moment to extract it into a shared location - not before, and not speculatively. Actual reuse is the evidence that a function belongs somewhere general. One usage is evidence it belongs exactly where it is.</p>\n<p>Extracting early is premature abstraction, which is the structural cousin of premature optimization - and causes the same class of damage. You pay the cost of the indirection immediately, in added complexity and navigation overhead, while the benefit remains hypothetical. The shared library grows. The reuse never comes. The cost was real; the justification wasn't.</p>\n<p>This matters for the same reason feature folders matter. A helper function sitting in the file that uses it is immediately understandable in context. You read the function, you see what it does, you see what calls it, and the picture is complete. A helper function extracted to a shared utilities file requires you to go there, understand it out of context, and mentally reconnect it to the place that uses it. That round trip is small, but it accumulates, and it means the utilities file eventually becomes a place where functions go to be forgotten - used by one caller, never reviewed, never deleted, outliving the feature that originally needed them.</p>\n<p>The deletion argument is particularly clean here. When a feature disappears, its helper functions disappear with it if they lived in the feature file. When those helpers were moved to a shared location &quot;for reuse that never came,&quot; they stay behind as dead code that nobody feels confident removing because nobody remembers what it was for.</p>\n<blockquote>\n<p>Extract when there are two callers. Until then, keep it close.</p>\n</blockquote>\n<hr />\n<h2 id=\"tailwind-is-code-proximity-for-styling\">Tailwind Is Code Proximity for Styling</h2>\n<p>I want to spend a moment on Tailwind, because it illustrates the same principle from an angle that provokes strong reactions from people who haven't spent enough time with it.</p>\n<p>A class like <code>card card-large</code> is a semantic name. It describes intent. It tells you this element is a card, and it is large. What it does not tell you is what a card looks like, what large means, what colors are involved, what the spacing is, whether it's a flex container, what the border radius is. To know any of that, you leave the template and go find the stylesheet.</p>\n<p>A Tailwind class list like <code>flex items-center gap-4 p-6 bg-white rounded-lg shadow-md border border-gray-100</code> tells you all of it, right there. Someone who knows the utility classes - and they are learnable, they are consistent, they follow a logic you internalize quickly - can read that string and visualize the element with reasonable accuracy without opening another file. The margin, the padding, the flex layout, the background, the border, the shadow. It's all in the class attribute.</p>\n<p>This is not verbosity for its own sake. It is the elimination of an indirection. The styling intent is co-located with the element being styled, which means the element is self-describing in a way that semantic class names never quite are.</p>\n<blockquote>\n<p>An experienced eye reads a Tailwind class list the way a musician reads sheet music - the notation <em>is</em> the sound. You don't need to go find a recording to know what it will sound like.</p>\n</blockquote>\n<p>The counter-argument is that Tailwind classes are hard to read until you know them. That's true, and it's also true of <code>card card-large</code> - it's just hard to read in a different direction. With an unknown utility class, you can look it up and learn it once. With an unknown semantic class, you have to find the definition every time, and the definition itself is arbitrary rather than systematic.</p>\n<p>The reasonable follow-up is: what about reuse? Writing the same cluster of Tailwind classes for every text input across the application is its own kind of problem. The answer isn't to go back to semantic class names - it's components. Reepolee supports custom ReeTag components: you define <code>&lt;input-text&gt;</code> once in a <code>.ree</code> file, use it everywhere, and it compiles server-side with no runtime overhead. The Tailwind classes live inside the component definition, visible to anyone who opens the file.</p>\n<p>And yes - this is a contradiction, or at least a tension. A ReeTag component does hide the implementation from the call site, which is exactly what we said was the problem with <code>card card-large</code>. The difference is that the hiding is one level deep and deliberately bounded: you choose to encapsulate <code>&lt;input-text&gt;</code> because you'll use it fifty times and changing the styling of every text input from one place is worth the trade-off. The Tailwind classes are still there, in one file, readable in thirty seconds. It is not a stylesheet spread across thousands of lines with specificity wars buried in it.</p>\n<p>This is the fine line every developer has to find for themselves. There is no rule that draws it cleanly. Too little encapsulation and you're copying Tailwind class strings everywhere, fragile and inconsistent. Too much and you're back to <code>card card-large</code>, opaque and unmaintainable. The right answer sits somewhere in the middle and moves depending on the project, the team, and how long the thing needs to live. What I can say is that the instinct toward encapsulation should come from demonstrated reuse - the same logic as extracting a helper function - not from a desire to make the call site look tidy. And once you have those components, the question of what they should <em>do</em> - whether they should behave like native browser elements or like something custom - is a separate argument, covered in <a href=\"/blog/boring-ui-wins\">Boring UI Wins</a>.</p>\n<hr />\n<h2 id=\"deletion-prs-and-the-new-developer\">Deletion, PRs, and the New Developer</h2>\n<p>Three practical consequences of feature-per-folder that don't get enough attention:</p>\n<p>Deleting a feature is deleting a folder. In a separated architecture, removing something requires hunting across the layer tree for every piece of it - the model, the controller action, the route, the view, the partial, the helper, the test file in yet another mirrored directory. You miss things. The dead code stays. In feature-per-folder, you delete the folder and the feature is gone, completely, by construction.</p>\n<p>Code review for a feature touches one folder. A pull request that implements &quot;add invoice export&quot; should show changes concentrated in one place, readable in sequence, reviewable without a mental map of the project. When that PR instead shows eight files across four directory levels, the reviewer has to reconstruct the feature's logic from fragments rather than reading it as a whole.</p>\n<p>A new developer can understand a feature by looking at one folder. They don't need to understand the whole layer architecture before they can follow a single request. They open the users folder and the users feature is right there - not split across three trees they haven't learned yet.</p>\n<hr />\n<h2 id=\"not-every-feature-stands-alone\">Not Every Feature Stands Alone</h2>\n<p>It would be dishonest to take this argument to its logical extreme and claim every piece of code can live in a feature folder with no shared dependencies. It can't. Authentication touches everything. A database connection is not owned by any single feature. A logging utility, a base layout, a shared validation rule - these things exist because the alternative is duplicating them in every folder, which trades one problem for a worse one.</p>\n<p>The point isn't that shared code is wrong. It's that shared code should be <em>earned</em> by the same logic as extracted helper functions - by demonstrated need across multiple features, not by architectural habit or the assumption that something might be reused someday. And more importantly, the developer has to be the one who decides where those lines are. The folder structure of a project is a statement about how you intend it to grow.</p>\n<blockquote>\n<p>A project where everything bleeds into shared layers is a project without a stated direction.</p>\n</blockquote>\n<p>A project where every feature folder is genuinely self-contained for its own concerns, and shared code exists only where sharing was unavoidable, is a project someone made decisions about. That difference is visible three years later when the person maintaining it isn't the person who wrote it.</p>\n<hr />\n<h2 id=\"why-i-keep-coming-back-to-this\">Why I Keep Coming Back to This</h2>\n<p>I have worked on projects that used separation of concerns in the traditional sense - full MVC, strict layer discipline, everything in its architectural place. Some of those projects were well-organized by the standards of the approach. They were still harder to work in than they needed to be, and the difficulty grew with the project rather than staying constant.</p>\n<p>What I notice, every time I come back to a feature-per-folder, single-file approach - and I have come back to it enough times now that I've stopped treating it as a preference and started treating it as a conclusion - is how much less I have to hold in my head. The code is where I expect it to be because the organization reflects how I think about the domain, not how the framework thinks about its own internals. After three years on a project, opening a feature folder still feels like opening a drawer that has exactly what I put in it.</p>\n<p>That feeling is not aesthetic. It's a reduction in the time spent reconstructing context rather than doing work.</p>\n<hr />\n<h2 id=\"how-reepolee-treats-this\">How Reepolee Treats This</h2>\n<p>Reepolee is structured around these ideas from the ground up. Generated resources land in feature folders, not in a layer hierarchy. The <code>.ree</code> templates keep everything a view needs in one file. The SQL lives next to the route that uses it. Translations are generated per-resource rather than accumulated in a single global file that grows without obvious structure.</p>\n<p>When you add a resource, a folder appears. When you remove one, the folder goes with it. The generated structure reflects the domain - what your application is about - rather than the framework - what your application is built with.</p>\n<p>For small to mid-sized applications, where the team is small and the codebase needs to stay navigable over years rather than months, this matters more than it might appear. The overhead of a separated architecture is manageable when a team of twenty is maintaining it with dedicated ownership of each layer. It's a real drag when two or three developers are moving across the whole system and need to keep the whole feature in view at once.</p>\n<p>A useful signal: open the project and navigate to a feature you haven't touched in six months. How many files do you need to open before you understand what it does? In a well-organized feature-per-folder project, we find the answer is one or two. If it's five or six, the structure is working against you.</p>\n",
      "date_published": "2026-06-20T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/name-your-columns-like-you-mean-it/",
      "url": "https://www.reepolee.com/engineering-notes/name-your-columns-like-you-mean-it/",
      "title": "How We Name Our Columns and Why It Matters",
      "summary": "On establishing inhouse naming rules before generators run - column patterns, soft deletes, and a case for snake_case across the entire codebase, not just the database.",
      "content_html": "<p>There is a category of decision in software that looks small at the time and turns out to be large, and column naming is one of them. Not because the name of a column is intrinsically important, but because once a schema is in production, that name is everywhere: in every query, every route handler, every form field, every validation message, every log line, every API response. Renaming it is a migration, a code search, a review, a deployment, and a prayer that you found every reference. It is the kind of work that gets postponed indefinitely because the cost is high and the benefit is invisible to anyone who wasn't there when the name was chosen.</p>\n<p>If you are using a generator that derives code from your schema - and I would argue it pays off, for the reasons covered <a href=\"/blog/generators-not-llms-for-everything/\">elsewhere on this blog</a> - the stakes are higher still. A naming decision made in the schema gets amplified across every artifact the generator produces, so getting it right before the generator runs matters more than it might seem, because getting it wrong means the wrong name propagates everywhere consistently, which is almost worse than inconsistency because it takes more effort to fix.</p>\n<hr />\n<h2 id=\"make-inhouse-rules-and-commit-to-them\">Make Inhouse Rules and Commit to Them</h2>\n<p>The first thing to accept is that your column naming conventions are yours and don't need to align with any external standard, any ORM expectation, or any API convention the community has settled on this year, because the people who will read these names are your team, working in your codebase, on your project, and optimizing for them is the only goal that matters.</p>\n<p>What this means in practice is sitting down before the first migration runs and deciding on patterns, not column by column but by category: what do foreign keys look like, what do boolean flags look like, what do timestamps look like, what about codes and names and titles. Answer those questions once, write them down, and apply them consistently forever, because the consistency is the point. A codebase where every developer applied their own instincts to column naming is a codebase where you have to read each column to know what it is. A codebase with a convention is one where you know what a column is before you finish reading its name.</p>\n<hr />\n<h2 id=\"examples-of-patterns-that-have-held-up\">Examples of Patterns That Have Held Up</h2>\n<p>These are not a complete list and they are not meant to be - they are examples of the kind of categorical thinking that makes a schema readable, the patterns our own projects have settled on after enough iterations to trust them, and the goal is not to copy these suffixes but to arrive at a set of conventions with the same internal logic: consistent, recognizable, and decided before the first migration runs.</p>\n<p><code>*_id</code> for foreign keys. <code>user_id</code>, <code>company_id</code>, <code>invoice_id</code>. The suffix tells you this is a reference rather than a value, so you don't need to open the schema to know it points somewhere else.</p>\n<p><code>*_at</code> for timestamps. <code>created_at</code>, <code>updated_at</code>, <code>deleted_at</code>, <code>disabled_at</code>. The suffix tells you this is a moment in time when something happened, and it reads naturally in queries and in code because the grammar of the column name matches the grammar of what you say about it.</p>\n<p><code>is_*</code> and <code>has_*</code> for booleans. <code>is_active</code>, <code>is_verified</code>, <code>has_subscription</code>, <code>has_children</code>. The prefix makes the boolean nature of the column obvious without opening the schema, where <code>is_</code> signals state and <code>has_</code> signals possession, a small distinction that adds meaningful clarity when the column list is long.</p>\n<p><code>*_code</code> for short identifiers and enumerated values. <code>country_code</code>, <code>status_code</code>, <code>currency_code</code>. These are not names for human display; they are codes your application logic switches on. The suffix signals that - and it signals something else worth naming: a <code>*_code</code> column is often a soft foreign key. It relates to another table by value rather than by enforced constraint. <code>country_code</code> points to a countries table the same way <code>country_id</code> would, except the relationship is known by convention rather than declared to the database. That distinction matters when you're reading the schema: <code>*_id</code> means a hard reference with a constraint behind it; <code>*_code</code> means a known relationship, intentionally kept flexible.</p>\n<p><code>*_name</code> for human-readable labels. <code>first_name</code>, <code>last_name</code>, <code>company_name</code>. These are display values, searchable, what you show in a list.</p>\n<p><code>*_title</code> for longer descriptive strings with hierarchy implied. <code>job_title</code>, <code>post_title</code>, <code>page_title</code>. Similar to name but carrying a sense of heading or role, with a slight implication of something that belongs in an <code>&lt;h1&gt;</code> rather than a table cell.</p>\n<p>None of these are invented, because they are patterns that emerge from building enough applications that you start noticing what makes a schema readable at a glance versus what makes you reach for the schema docs every time you write a query.</p>\n<hr />\n<h2 id=\"deletedat-and-disabledat-instead-of-boolean-flags\">deleted_at and disabled_at Instead of Boolean Flags</h2>\n<p>This one is worth its own section because it runs counter to the instinct most developers have the first time they need to implement soft deletion.</p>\n<p>The instinct is a boolean: <code>is_deleted</code>, <code>is_active</code>, flip it when the record is removed or disabled, filter on it everywhere, and while it works, it is a one-bit answer to a question that usually has more dimensions than one bit can hold.</p>\n<p>A timestamp column like <code>deleted_at</code> or <code>disabled_at</code> gives you the same filter - <code>deleted_at IS NULL</code> for active records, <code>deleted_at IS NOT NULL</code> for deleted ones - but it also gives you when. When was this record deleted? When was this user disabled? That information is frequently useful for auditing, for support, for data analysis, and for the inevitable moment when someone asks &quot;can we restore records deleted in the last thirty days.&quot; With a boolean, the answer is complicated. With a timestamp, the answer is a WHERE clause.</p>\n<p>The other advantage is that IDs survive. A record marked as deleted is still in the table with its original ID intact. Every foreign key that pointed to it still resolves. Every audit log that referenced it still makes sense. Hard deletion breaks referential history in ways that tend to surface slowly and expensively. Soft deletion with a timestamp preserves it completely.</p>\n<p>The filter discipline required - remembering to add <code>WHERE deleted_at IS NULL</code> to every query that should only return active records - is real overhead, but it is the kind of overhead a generator handles for you automatically if the convention is established. Reepolee addresses this through global scopes: named WHERE clauses defined once per table and applied automatically to generated queries. You define an &quot;active&quot; scope for a table as <code>deleted_at IS NULL</code>, set it as the default, and every generated list query applies it without you thinking about it. Scopes can also reference the current user's session - <code>author_id = ::session.user.id</code> - which means row-level visibility rules follow the same pattern: defined once, applied consistently, never forgotten in a query written at 11pm. That is a subject worth its own post, but the foundation is the naming convention: a generator can only understand <code>deleted_at</code> if you named it that way consistently.</p>\n<hr />\n<h2 id=\"snakecase-in-typescript-even-if-the-community-disagrees\">snake_case in TypeScript, Even If the Community Disagrees</h2>\n<p>JavaScript and TypeScript use camelCase by convention, and most ORMs, most API design guides, and most JSON response examples you'll find online follow it, which makes the community expectation strong enough that going the other way requires a clear reason.</p>\n<p>I use snake_case from the database all the way through to TypeScript, and I don't apologize for it, because the reason is simple and practical: <code>user_id</code> in TypeScript tells you immediately that this value came from your database, or is on its way there. It is not a language value, not a library type, not a third-party API response - it is yours. The underscore is a visual marker that says &quot;this is our domain, our schema, our code.&quot; In a file that mixes your domain objects with library types and external API responses, that distinction is useful. You can tell at a glance which values you own and which you're borrowing.</p>\n<p>The convention doesn't stop at column names, because functions follow it too, like <code>get_user_by_id</code>, <code>validate_invoice_total</code>, <code>resolve_session_variables</code>, and so do files and variables: <code>global_scopes.ts</code>, <code>input-text.ree</code>, <code>const invoice_total</code>, <code>let current_user</code>. The whole codebase speaks one dialect. When everything that belongs to your project follows snake_case and everything that comes from the language or a library follows camelCase, the boundary between your code and borrowed code is visible at a glance. That visibility has saved more debugging time than I can account for.</p>\n<p>There is also something honest about it, because camelCase in your TS types that maps to snake_case in your DB requires a transformation layer - either explicit mapping or ORM magic that converts between the two silently. That transformation is complexity that solves a problem of appearances: it makes your TypeScript look conventional at the cost of hiding the mapping. snake_case throughout eliminates the transformation entirely. What you write in TypeScript is what the database stores. What the database returns is what you use directly. No mapping, no surprises, no layer to debug when something doesn't convert the way you expected.</p>\n<p>There is also a mundane, practical argument that only becomes obvious the first time you need to find every column of a given type across the codebase. Search for <code>_code</code> and you get <code>product_code</code>, <code>country_code</code>, <code>status_code</code> - exactly the columns you named with that suffix, nothing else. Search for <code>code</code> and you get those plus every variable named <code>code</code>, every function parameter, every comment that happens to contain the word. The underscore is the differentiator. It makes your naming convention grep-able. The same applies to renaming: replacing <code>product_code</code> touches only what you intend. Replacing <code>productCode</code> in a mixed codebase risks catching camelCase values from libraries or external APIs that happen to share the name. Snake_case column names are precise search targets in a way that camelCase names, embedded in a language that uses camelCase for everything, simply are not.</p>\n<p>This runs against community convention, and I want to be direct about why that doesn't concern me much. The community reflects the average of many projects with many different constraints, and no project is average. We tried snake_case end to end, observed what happened over two years of real maintenance, and formed a view, and if you land somewhere different after the same exercise, say so clearly, because that is how conventions improve - and the community will disagree regardless, while also changing its conventions more often than your schema does.</p>\n<hr />\n<h2 id=\"before-the-generator-runs\">Before the Generator Runs</h2>\n<p>All of this comes before the first line of generated code. That is not a coincidence - it is the point. A generator is an amplifier. It takes the decisions encoded in your schema and propagates them into every artifact it produces. Good decisions, propagated consistently, produce a codebase that reads like it was designed. Poor decisions, propagated consistently, produce a codebase that is wrong in exactly the same way everywhere, which is harder to fix than a codebase that is wrong in varied ways, because the fix has to happen in the generator's logic rather than file by file.</p>\n<p>Reepolee reads the schema as its single source of truth. It sees <code>deleted_at</code> and knows what it means. It sees <code>user_id</code> and knows it's a foreign key. It sees <code>is_active</code> and knows to render a checkbox. It generates snake_case TypeScript types that match your snake_case columns without transformation. The conventions you establish before running the generator are the conventions the generated code inherits - which means getting them right is not just about the schema, it is about every file that schema will ever produce.</p>\n<p>Decide the names first, and the rest follows from there.</p>\n",
      "date_published": "2026-06-20T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/no-deps-no-build/",
      "url": "https://www.reepolee.com/engineering-notes/no-deps-no-build/",
      "title": "No Deps, No Build",
      "summary": "On building zero-dependency, no-build web applications with Bun for nonprofits, education, intranets, and small teams - and why appropriate complexity is a feature, not a compromise.",
      "content_html": "<p>There is a version of web development that looks like this: <code>git pull</code>, restart the process, done, no build step, no <code>npm install</code> on the server, no pipeline with six stages and a Slack notification when it finishes, no <code>node_modules</code> folder that takes longer to copy than the actual code, just the application, running. This is not a fantasy but precisely what Reepolee is built toward, and this post is about why that matters and for whom.</p>\n<hr />\n<h2 id=\"not-every-application-is-a-saas-product\">Not Every Application Is a SaaS Product</h2>\n<p>The web development conversation is dominated by the requirements of a specific kind of software: high-traffic consumer products, SaaS platforms, anything that needs to scale horizontally and deploy continuously and survive a mention on a popular website. The tooling, the conventions, the best practices - most of it evolved to serve that context.</p>\n<p>But a very large number of applications don't live in that context - the booking system for a community center, the member directory for a trade union, the volunteer schedule for a food bank, the incident register for a facilities team. These applications have tens or hundreds of users, not millions. They run on a single VPS that costs a few euros a month. They are maintained by one developer, possibly part-time, possibly a volunteer.</p>\n<p>For these applications, a bundler config is not a feature, a Docker container is not a requirement, and a deployment pipeline is not a safety net but overhead that one person has to understand, maintain, and debug when it breaks at the wrong moment.</p>\n<p>The appropriate amount of complexity for a small application is as little as possible. Not because simplicity is aesthetically pleasing, but because every layer of complexity you add is a layer that can fail, a layer that needs updating, a layer between you and the running code when something goes wrong.</p>\n<hr />\n<h2 id=\"bun-is-becoming-an-application-server\">Bun Is Becoming an Application Server</h2>\n<p>Something has been happening with Bun that deserves naming directly: it is increasingly difficult to describe it as just a JavaScript runtime. Look at what it ships natively - an HTTP server with routing, a SQL client that supports MySQL, SQLite, and PostgreSQL, file I/O, streams, workers, WebSockets, S3 and blob storage APIs, password hashing, a test runner, a package manager, and direct TypeScript execution without a compilation step.</p>\n<p>That is not a runtime in the traditional sense but most of what a web application needs to function, packaged into a single binary.</p>\n<p>The things that used to require Express, a database driver, a session library, a file upload handler, a WebSocket library, a streaming response package - Bun simply has them. When a feature needs real-time updates, WebSockets are available natively - no library, no adapter, no compatibility shim. When a response needs to stream - a large file, a server-sent event feed, a live log - the streaming APIs are there, part of the same runtime, working the same way as everything else. Not as add-ons or packages you install, but as part of the runtime you already have. For a small to mid-sized application serving a bounded audience, the native Bun APIs cover the majority of the surface without reaching for a single external package.</p>\n<p>This is why zero runtime dependencies is achievable now in a way it wasn't three years ago, because Bun did the work of consolidating what previously required a dozen packages into a single, coherent runtime.</p>\n<p>One more thing worth saying about Bun, because the framing matters: stop thinking about it as a Node.js alternative. That framing - faster Node, drop-in Node replacement, Node but better - undersells what it actually is and anchors the conversation to a comparison that becomes less relevant with every release. Node compatibility is a welcome bonus that eases migration. It is not the point. The point is a runtime that was designed from the start with a coherent, wide native API, a fast execution model, and a clear opinion about what a JavaScript application server should be able to do without reaching for packages. That is a different thing from Node, not a better version of it.</p>\n<p>Which means the shift worth making is not &quot;run my Node code on Bun.&quot; It is &quot;develop for Bun.&quot; Use <code>Bun.serve()</code> instead of Express. Use <code>new SQL()</code> instead of a database driver package. Use <code>Bun.file()</code> instead of <code>fs</code>. Write code that assumes Bun's native APIs are available, because they are, and because doing so is what lets you drop the dependencies, skip the build step, and deploy with a <code>git pull</code>. Treating Bun as a faster Node runner and continuing to reach for the same packages is leaving most of the value on the table.</p>\n<p>The pattern isn't new. Go emerged from frustration - developers who were tired of slow compile times, fragmented tooling, and the ceremony of C++ and Java for server work. The Go team made a deliberate decision to build a wide standard library, to ship formatting and testing and building as part of the language itself, because they had watched the ecosystem solve those problems badly enough times to know that the ecosystem shouldn't be solving them at all. The result was a runtime with an opinion about what belongs inside it - and that opinion turned out to be correct.</p>\n<p>Bun is not a new language. But the frustration it responds to is the same: slow startup, npm install as a ritual, needing a bundler and a test runner and a package manager and a TypeScript compiler as four separate tools to do what should be one thing. The response is also the same: one binary, wide native API, fast, with a clear view of what an application runtime should handle so that the application itself doesn't have to. Same philosophy, different decade, different language. The skepticism it faces sounds familiar too.</p>\n<p>I've been through this before. When Node.js arrived, the reaction from large parts of the industry was dismissive to the point of hostility. JavaScript belongs in the browser. Use a real language for the server. It will never scale. It has no future. The people who ignored that and built with Node anyway were treated like they were making an obviously wrong choice - until the rest of the industry caught up and the &quot;obviously wrong choice&quot; became the default. Bun is the same evolution, one step further. The skepticism sounds familiar because it is the same skepticism, recycled.</p>\n<blockquote>\n<p>Early adopters know what it feels like to be right before the consensus agrees.</p>\n</blockquote>\n<p>There is a counterargument worth addressing directly, because it comes up whenever Bun's built-in APIs are discussed: this is wrong, the argument goes, APIs should be packages. The runtime should be minimal and composable. Let the ecosystem decide what belongs.</p>\n<p>I disagree, and I think the previous section on supply chain attacks explains why better than any philosophical argument could. Every API that ships as a package instead of as part of the runtime is a dependency with a maintainer who might disappear, a version that might introduce a breaking change, a release that might contain malicious code, and an <code>npm install</code> that pulls it and everything it needs into your project. Multiply that by the dozen APIs a typical web application needs and you have the situation JavaScript has been in for years: projects with hundreds of transitive dependencies, most of which nobody on the team has ever read.</p>\n<p>A single, well-maintained runtime with a wide native API is strictly better than the equivalent collection of packages, for every property that matters in production. It has one release cycle, one security team, one changelog to read. When Bun ships a SQL client, that client is tested against the same runtime your code runs on, maintained by the same people, versioned together. When you install a third-party SQL package, you have introduced a dependency whose test coverage, maintenance posture, and compatibility guarantees are somebody else's problem until the day they become yours.</p>\n<p>The &quot;runtime should be minimal&quot; philosophy made sense when the ecosystem was young and the right abstractions weren't obvious. We know now what a web application needs. A runtime that ships those things natively isn't bloated - it's mature. A slightly larger binary in exchange for eliminating an entire class of dependency risk is not a trade-off. It's the better trade.</p>\n<hr />\n<h2 id=\"what-zero-runtime-dependencies-actually-means\">What Zero Runtime Dependencies Actually Means</h2>\n<p>Reepolee's <code>package.json</code> has two dev dependencies: TypeScript types for Bun, and the Tailwind CSS CLI, and that is the entire list, because the running application on the server has no npm packages at all.</p>\n<p>The developer tools - the CSS compiler, the formatter, the linter, the code generator - are installed globally on the developer's machine. They live there, they work there, and they never appear on the VPS. The server doesn't know they exist. This is the relationship that works well between development tooling and production infrastructure: the tools that help you build should not be part of what you deploy.</p>\n<p>Third-party JavaScript libraries that do appear in the application - a validation library, a syntax highlighter - are vendored: downloaded once as minified ESM files, committed to the repository, served directly by the application. No <code>npm install</code> pulls them. No CDN serves them. They are just files in the repo, stable, auditable, and present whether or not the registry is up.</p>\n<p>The result is a server that needs exactly two things: Bun and the repository, which means <code>git pull</code> and a process restart, with no install step that could fail because a package has been unpublished, no build step that requires the right Node version, and no <code>node_modules</code> directory that has to be synchronized or cached or reproduced.</p>\n<hr />\n<h2 id=\"the-ree-templates-and-server-side-rendering\">The .ree Templates and Server-Side Rendering</h2>\n<p>The application generates HTML on the server, not through a client-side framework that downloads JavaScript and hydrates and then renders, but by having the server read a request, run the template, and send a complete page that the browser receives and displays without any further ceremony.</p>\n<p>The <code>.ree</code> template format compiles on the server at request time, with no bundle to generate and no client-side JavaScript framework to initialize, because the template is the view, the server is the engine, and the output is what the browser needs to display the page - nothing more. Client-side JavaScript lives in the template file when it's needed for a specific interaction, served as-is, without a bundler in the chain.</p>\n<p>For the kind of application this post is about - a sports club's registration portal, a community clinic's appointment diary, a small library's lending tracker - this is not a constraint but the right fit, because the entire model fits in one sentence: the user navigates to a page, the server sends it, and the browser shows it.</p>\n<hr />\n<h2 id=\"complexity-has-a-cost-youre-not-always-shown\">Complexity Has a Cost You're Not Always Shown</h2>\n<p>The case for build pipelines and dependency ecosystems is usually made in terms of capability: look at what you can do with this. The case against is made in terms of cost, and that case is less often made explicitly.</p>\n<p>Every dependency is a thing that can have a vulnerability, be abandoned by its maintainer, change its API without warning, or simply stop working when another package in the tree updates.</p>\n<p>The supply chain attack deserves more than a mention in passing, because it is one of the most underappreciated risks in modern JavaScript development and it scales directly with your dependency count. When you run <code>npm install</code>, you are not just installing the packages you listed. You are installing everything those packages depend on, and everything those dependencies depend on, recursively. A medium-sized JavaScript project can easily have hundreds of transitive dependencies - code written by people you have never heard of, reviewed by nobody on your team, running in your production environment with access to your file system, your environment variables, and your network.</p>\n<p>This has gone wrong in visible, public ways. A widely used utility package was compromised and published malicious code that spread to thousands of projects before anyone noticed. A developer intentionally broke their own package used by millions, in protest. Another injected code that targeted specific IP ranges. These are not edge cases or theoretical scenarios - they are things that happened, documented in public post-mortems, and the common thread in each of them is that the attack surface was the dependency tree itself.</p>\n<p>Vendoring changes this relationship entirely. When a third-party library is a file in your repository - downloaded once, inspected, committed - it cannot be silently updated by someone else. It does not change between your last deploy and tonight's. It does not disappear because the author deleted their account. You know exactly what it is, because you can read it. The smaller your dependency list, the smaller your attack surface, and the more you know about what is actually running on your server.</p>\n<p>Every build step is a thing that can fail. The CSS compilation, the bundling, the transpilation, the minification - each of these is a process with configuration, with version requirements, with environmental assumptions. Each one has failed for someone at the worst possible moment. Removing build steps from the production path removes that category of failure entirely.</p>\n<p>None of this means build tools are wrong, but it does mean that every tool you add should earn its place by providing value that exceeds its cost, and for a small application with a small team and a bounded audience most of them don't.</p>\n<hr />\n<h2 id=\"who-this-is-for\">Who This Is For</h2>\n<p>A system that deploys with a <code>git pull</code> and restarts with one command is not a toy. It is a well-suited tool for a specific and very common context: applications where the audience is known, the scale is bounded, the team is small, and the most important thing is that the system works correctly and can be understood and maintained by the people responsible for it.</p>\n<p>Credit unions, volunteer organizations, youth sports leagues, professional associations, municipal offices, internal ops teams - any organization that needs real software but doesn't have an engineering team to run a deployment infrastructure.</p>\n<p>For these contexts, the honest comparison is not &quot;this versus a modern SaaS deployment&quot; but &quot;this versus the spreadsheet it's replacing, or the off-the-shelf system that costs ten times as much and does half of what's needed,&quot; and against that comparison a zero-dependency, no-build application that runs on a five-euro VPS and deploys in seconds is not a compromise.</p>\n<p>Reepolee is built for exactly this kind of context, where the generator produces the application, Bun runs it, the server hosts it, and nothing else is required, because that absence is the feature.</p>\n<p>The natural next question is where that server lives - and the answer matters more than most developers give it credit for. That's a conversation about on-premise deployments and owning your own VPS, which deserves its own post.</p>\n",
      "date_published": "2026-06-20T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/on-premise-or-your-own-vps/",
      "url": "https://www.reepolee.com/engineering-notes/on-premise-or-your-own-vps/",
      "title": "On-Premise, or at Least Your Own VPS",
      "summary": "On cost, control, privacy, GDPR, local network performance, and what happens to your business when the internet goes down or a cloud provider's building catches fire.",
      "content_html": "<p>In March 2021, a data center in Strasbourg burned. The fire destroyed one building completely and damaged another. Millions of websites went offline. Some of them never came back - not because the data was gone beyond recovery in principle, but because there were no backups, no contingency, no plan for a scenario that almost nobody had seriously considered. The company running the facility was one of the largest hosting providers in Europe. None of that mattered when the building was on fire.</p>\n<p>I am not telling this story to argue that cloud hosting is dangerous. I'm telling it because it happened, it is documented, and it is the clearest possible illustration of a point that gets lost in the comfortable assumption that someone else's infrastructure is inherently more reliable than your own: availability depends on factors you don't control, and the question of where your software runs is a business decision with real consequences, not a technical detail to leave to whoever set up the account.</p>\n<p>It is also not a one-time story. In October 2025, a DNS failure in AWS's US-EAST-1 region took services down for fifteen hours, affecting over four million users and more than a thousand companies. In June 2025, an IAM service failure at Google Cloud cascaded into a broader internet disruption that hit YouTube, Discord, Shopify, and Spotify simultaneously. Azure outages in the same period averaged nearly fifteen hours each when they happened. The major providers collectively logged over a hundred significant incidents in a twelve-month window. This is not a fringe risk. It is a recurring pattern at scale, and the organizations that absorbed it best were the ones that had thought in advance about what happens when the dependency fails.</p>\n<hr />\n<h2 id=\"the-cost-argument-is-the-simplest-one\">The Cost Argument Is the Simplest One</h2>\n<p>A VPS at five euros a month is five euros a month, and next month it will still be five euros, and the month after that the same - predictable enough to budget, explain to a board, and forecast across three years without a spreadsheet model.</p>\n<p>Cloud pricing is a different animal. The base instance is cheap. The egress costs are not. The managed database adds up. The load balancer, the CDN, the object storage, the monitoring, the redundancy you're told you need - each line item is small, and together they become a number that arrives at the end of the month and occasionally surprises even the people who approved the architecture.</p>\n<p>For the size of application this series of posts has been discussing - a dental practice's appointment system, a homeowners association's maintenance tracker, a workshop's equipment log - the cloud's elasticity is a feature you are paying for and never using. You don't need to scale to ten servers during a traffic spike. You have no traffic spikes. You have a hundred users who log in between nine and five. A single VPS handles that without breaking a sweat, at a price you can predict to the cent.</p>\n<p>On-premise hardware is a larger upfront cost and a lower ongoing one. A server that costs a thousand euros and runs for five years works out to less than seventeen euros a month before electricity, and the electricity for a low-power server in an office is noise. Organizations that have made this calculation honestly often find that the cloud was convenient but not economical - and convenience is worth something, but not unlimited.</p>\n<hr />\n<h2 id=\"control-is-not-a-technical-preference\">Control Is Not a Technical Preference</h2>\n<p>When your software runs on infrastructure you own, you make the decisions - the database configuration, the backup schedule and destination, the update cadence, the network rules, the access logs - and when something goes wrong you look at your own system rather than a support ticket queue where the answer might arrive in three business days.</p>\n<p>When your software runs on someone else's infrastructure, those decisions belong to them, within the limits of whatever plan you're on. This is usually fine. It becomes relevant the moment something unusual happens - a security concern that requires immediate access to specific logs, a compliance requirement that demands data be handled a particular way, a failure that needs investigation at a level the provider's tooling doesn't expose.</p>\n<p>Control is also about the future. A provider can change pricing, discontinue a service, alter terms of service, or be acquired. Each of these has happened, repeatedly, to services that organizations had built dependencies on. On-premise infrastructure doesn't come with that risk. Your server in your rack is yours until you decide otherwise.</p>\n<p>The natural objection here is that running your own server requires an ops engineer. It doesn't - not anymore, and arguably not for a long time. The knowledge required to run a Linux server hosting a small application is widely available, well-documented, and frankly not that much knowledge. A developer who can write a web application can learn to deploy one. The commands involved - installing Bun, cloning a repository, configuring a service - are not exotic. The concepts involved - a process that runs, restarts when it crashes, starts on boot - are not advanced operations.</p>\n<p>Linux's systemd handles the hard parts of keeping a process alive. A service definition that says &quot;run this command, as this user, restart if it exits, start when the machine boots&quot; is fourteen lines of configuration. Reepolee ships one, and installs it with a single script call - one command copies the service file into place, another enables it, another starts it. The application is running as a managed service. When the server restarts after a power cut or a kernel update, the application comes back up automatically. When something crashes, it restarts within seconds. No ops engineer required - just a developer who spent an afternoon reading about systemd and is comfortable with a terminal.</p>\n<p>The same pattern extends to everything else a running application needs. Database backups are a script and a cron job - dump the database on a schedule, copy it somewhere safe, rotate old copies. File backups are the same. Log rotation, health checks, deploy hooks - all of it is solved at the level of shell scripts and standard Linux tooling that has been stable for decades, is documented exhaustively, and requires no proprietary service to run. Reepolee ships scripts for the common cases. The rest is a search and an afternoon.</p>\n<hr />\n<h2 id=\"gdpr-and-data-sovereignty\">GDPR and Data Sovereignty</h2>\n<p>The General Data Protection Regulation has a clear and practical implication for where data lives: you need to know, and you need to be able to demonstrate compliance.</p>\n<blockquote>\n<p>&quot;It's in the cloud&quot; is not an answer.</p>\n</blockquote>\n<p>&quot;It's on our server in our country, accessed only by our staff, with these access controls and this backup policy&quot; is.</p>\n<p>For European organizations this matters legally - but the regulation is no longer a European concern alone. California has the CCPA and its successor the CPRA. Brazil has the LGPD. Canada has PIPEDA, with Quebec's Law 25 adding stricter requirements. The UK maintains its own post-Brexit version of GDPR. India passed the DPDPA in 2023. Almost every major jurisdiction either has a data protection framework in force or is actively drafting one. The trend is global and the direction is consistent: organizations must know where personal data lives, control who accesses it, and demonstrate both on request. Data processed by a cloud provider is subject to the provider's data processing agreements, their subprocessors, their data center locations, and the legal jurisdiction those locations fall under. These are auditable questions with real answers, but they require ongoing work to verify and maintain.</p>\n<p>Data on your own server in your own building is as simple as it gets for compliance purposes. You know where it is. You control who accesses it. You can produce an accurate record of both on request. For organizations that handle member data, patient data, student records, or any other category of personal information, this simplicity is not just convenient - it is the cleanest path through a regulatory requirement that only becomes more significant over time.</p>\n<hr />\n<h2 id=\"tunnels-have-changed-the-equation\">Tunnels Have Changed the Equation</h2>\n<p>The practical objection to on-premise hosting used to be a real one: getting traffic from the internet to a server in your building required a static IP, port forwarding, firewall rules, and a level of network administration that put it out of reach for small organizations without a dedicated IT person. That objection is largely gone.</p>\n<p>Tunneling tools - lightweight agents that run on your server and maintain an outbound connection to an edge node - can expose a locally running application to the internet in minutes, without touching the router, without a static IP, without opening inbound ports. The connection goes out from your server, which means your firewall configuration stays simple and your server is not directly reachable from the internet. And as a side effect, you get a significant amount of infrastructure handled for you at the edge: SSL termination, HTTP/3, response caching, DDoS protection, bot filtering. The kind of things that used to require a dedicated ops setup now come along for free with the tunnel. For small organizations, this is the difference between &quot;we could never do on-premise&quot; and &quot;we set it up on a Tuesday afternoon.&quot;</p>\n<p>The same infrastructure works for remote employees. A VPN tunnel between an employee's device and the office server gives them access to the local application over an encrypted connection, as if they were sitting in the building. The latency is higher than local network access, but the data stays on your server, the access is controlled by you, and the employee doesn't need the application to live in a cloud they pay for separately.</p>\n<p>A VPS fits into this picture naturally as well: a small virtual server close to your users, running the application, with a tunnel or VPN connecting it back to the organization's network if needed. The cost is a few euros a month. The control is yours. The data lives where you decided it should live.</p>\n<hr />\n<h2 id=\"the-internet-goes-down\">The Internet Goes Down</h2>\n<p>Here is the argument that gets the least attention and matters the most for a specific category of organization: the internet is not always available, and cloud-only means internet-only.</p>\n<p>A broken fiber link. A misconfigured router at the ISP. A backhoe through a conduit. An undersea cable damaged by a ship anchor. These are not hypotheticals - they are things that happen regularly, in developed countries with modern infrastructure, to organizations that had no reason to expect them. When they happen, every application that lives in the cloud becomes unreachable, regardless of how well it was designed or how reliable the cloud provider's uptime record is. The failure is not on the provider's side. The failure is between the provider and your users.</p>\n<p>For a school this means nobody can take attendance, look up a student's records, or access the schedule; for a nonprofit it means staff can't look up a member's information while talking to them on the phone; for a warehouse inventory checks stop; for a medical clinic records are unavailable during a patient visit.</p>\n<p>An application running on a server in the building continues to work for everyone on the local network regardless of what is happening to the WAN. And for organizations that need external access to stay available even during an ISP outage, a capable router with a 5G backup link is an affordable addition that automatically fails over when the primary connection drops. What used to require enterprise networking budget is now a consumer-grade router feature. The combination - local server, backup WAN - means your application is available to internal users unconditionally and to external users with a level of redundancy that rivals most cloud setups at a fraction of the cost.</p>\n<p>An application running on a server in the building continues to work, which means staff can still access it, support can still respond to clients, the phone call with a member can still be resolved, the warehouse can still function, and the clinic can still look up the record, because the internet being down is an inconvenience rather than a work stoppage.</p>\n<hr />\n<h2 id=\"local-network-performance\">Local Network Performance</h2>\n<p>A cloud database query travels from your user's browser to your server, from your server to a database in some data center, and back. Each hop takes time. A well-optimized cloud setup might achieve five to twenty milliseconds for a simple query. That is fast by most standards, and for most applications it is more than sufficient.</p>\n<p>A database on the same server as the application, on a local 1GB network, answers in microseconds. Not milliseconds. Microseconds. The actual performance limit in any cloud or remote setup is the WAN link - the bandwidth and latency of the internet connection between the user and the server. That link is shared: the same connection carries the video call in the meeting room, the social media tab someone has open, the software update downloading in the background, the colleague on a real-time collaboration tool. All of it competes for the same pipe, and when the pipe is busy your cloud application feels it. On a local network none of that matters. The application traffic stays on the LAN, uncontested, regardless of what everyone else in the building is doing with the internet connection.</p>\n<blockquote>\n<p>The constraint is the CPU and the disk, not the wire.</p>\n</blockquote>\n<p>For an application used by staff in an office or a school, where every user is on the local network, the performance difference between a well-resourced on-premise setup and a cloud deployment is not theoretical - it is something users feel every day in the responsiveness of the interface.</p>\n<p>This matters more than it is usually given credit for, because performance is not just a technical metric. An application that responds instantly feels trustworthy and well-built. An application that hesitates - even by amounts a benchmark wouldn't flag as significant - creates friction that accumulates over hundreds of daily interactions into a perception of the software that affects whether people actually use it.</p>\n<hr />\n<h2 id=\"affiliates-branches-and-the-phone-call\">Affiliates, Branches, and the Phone Call</h2>\n<p>One more scenario worth naming, because it comes up in the kinds of organizations this post is written for: the affiliate or branch that needs access, and the support call that happens when things are complicated.</p>\n<p>An organization with multiple locations - a trade union with regional branches, a clinic with multiple sites, a company with branch offices - can connect those locations over a VPN to a central on-premise server and give everyone fast, reliable access to the same system without paying per-seat cloud fees or managing complex cloud networking. The infrastructure is a VPN tunnel and a server. It runs.</p>\n<p>And when a member calls with a question, when a client needs help, when something needs to be resolved in real time over the phone - the person on the other end of that call can look it up. Not wait for the internet to recover. Not apologize that the system is currently unavailable. Look it up, because the system is in the building and the building is still there.</p>\n<hr />\n<h2 id=\"the-right-question\">The Right Question</h2>\n<p>The question is not &quot;cloud or on-premise&quot; as an absolute. There are workloads that genuinely belong in the cloud - global products, systems that need to scale unpredictably, anything that requires geographic distribution. That is not what this post is about.</p>\n<p>The question is whether the default assumption - that cloud hosting is always the right answer, that on-premise is outdated, that a VPS is a compromise - is being applied without examination to contexts where it doesn't fit. For a nonprofit, a school, a municipality, a small business, a clinic, a cooperative - organizations with bounded audiences, predictable workloads, real privacy requirements, and staff who need the system to work regardless of what is happening to the internet - the honest evaluation often lands somewhere different.</p>\n<p>When the optics break in France, when the data center catches fire, when the fiber goes down at the worst possible moment - the organizations that knew where their software ran, controlled who touched it, and kept it close to the people who used it are the ones still working.</p>\n<p>Reepolee is designed to run exactly this way: a single server, a single process, no cloud dependencies, <code>git pull</code> and restart. The whole point is that it should fit in your building as naturally as it fits on a VPS.</p>\n<p>One distinction worth making: this post is about applications - the membership system, the intranet, the tool staff use daily. A public-facing website that exists to attract visitors is a different workload. It gets traffic from strangers, benefits from a CDN, and has no reason to live on-premise. Reepolee has a prerendering module for exactly this - the public site compiles to static files and deploys to whatever edge or static hosting makes sense, while the application that runs the organization stays on your server, behind your controls, close to the people who depend on it.</p>\n",
      "date_published": "2026-06-20T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/the-database-is-not-a-javascript-object/",
      "url": "https://www.reepolee.com/engineering-notes/the-database-is-not-a-javascript-object/",
      "title": "The Database Is Not a JavaScript Object",
      "summary": "A case for writing SQL directly - not because ORMs are wrong, but because the developer who reaches for one without learning what it replaces has chosen a ceiling.",
      "content_html": "<p>Let me be clear about what this post is not, because it is not a complaint about ORMs, which are competently built tools that solve a real problem for a certain kind of team in a certain kind of situation, and some of them are well-designed, and some projects ship successfully with them for years.</p>\n<p>What I want to talk about is the developer who reaches for one before learning what it replaces, and what that choice costs over the life of a project.</p>\n<hr />\n<h2 id=\"javascript-runs-on-the-server-now\">JavaScript Runs on the Server Now</h2>\n<p>The rise of Node, Deno, Bun, and now Nub, TypeScript everywhere, full-stack JavaScript - all of it has been genuinely good for the industry. Lower barrier to entry. Shared language across the stack. A runtime that keeps improving. I have no argument with any of that.</p>\n<p>But something came along with it that I've watched quietly cause problems for years: the idea that if it doesn't look like JavaScript, it should be wrapped in something that does. The database is the most obvious casualty of this instinct. SQL is a different language with a different mental model - set-based, declarative, relational - and a certain category of developer has decided that the natural response to that difference is to make it go away.</p>\n<pre><code class=\"language-js\">user.name = &quot;Aleš&quot;;\nawait user.save();\n</code></pre>\n<p>That line is comfortable because it looks like the rest of the application and doesn't ask you to think about anything that isn't already familiar, and that comfort is precisely the problem, because what it hides is a conversation with a system that has its own rules, its own performance characteristics, and its own ways of failing, none of which have gone away just because you've stopped looking at them.</p>\n<hr />\n<h2 id=\"what-youre-not-seeing\">What You're Not Seeing</h2>\n<p>When you write <code>user.update()</code>, something has to produce an UPDATE statement. Something has to decide which columns to include. Something has to construct the WHERE clause. Something has to decide whether this runs inside a transaction, and if so, which one. Something has to handle the case where the row was modified by another process between when you read it and when you wrote it.</p>\n<p>The ORM does all of this for you, which is its value proposition, but the cost is that it does all of this <em>invisibly</em>, according to rules you may not know and may not have checked, which is the classic dependency trap that applies to any package you reach for without understanding what it does beneath the surface, because you are now subject to its release cycle, its breaking changes, its bugs, its opinions about how your data should move. The ORM is just the most consequential version of this trap, because the thing it's abstracting is your data.</p>\n<p>A developer who understands SQL looks at <code>user.update()</code> and asks: what does that actually send to the database? A developer who doesn't understand SQL looks at <code>user.update()</code> and thinks: that updates the user. The gap between those two mental models is where production incidents live.</p>\n<p>The N+1 query problem is the most documented version of this gap. You fetch a list of orders. You loop over them to get each customer. The ORM obliges, running one query per order, and the page that renders in 40ms in development renders in 4 seconds in production with real data. The fix is a JOIN. A developer who knows SQL writes the JOIN. A developer who doesn't know SQL googles &quot;ORM eager loading&quot; and learns to configure the abstraction rather than understand the underlying system.</p>\n<blockquote>\n<p>Both can ship the feature. Only one of them controls it.</p>\n</blockquote>\n<hr />\n<h2 id=\"sql-is-not-hard\">SQL Is Not Hard</h2>\n<p>This is the part that needs saying plainly: SQL is not a difficult language to learn, and basic competency is achievable in a weekend, with genuine fluency following from a few months of real use where the relatively small syntax starts to feel natural and the concepts - SELECT, JOIN, GROUP BY, WHERE, transactions - stop looking foreign because they map directly to things you're already doing in your application code, expressed differently.</p>\n<p>What SQL requires is a shift in thinking, because it is set-based rather than record-based and you describe what you want rather than how to get it, so a JOIN isn't a loop but a declaration of a relationship, and GROUP BY isn't iteration but aggregation over a set. That shift is the actual learning, and it's worth making not because SQL is beautiful, which it isn't particularly, but because the database is one of the most important systems in most applications and understanding how it works is part of the job.</p>\n<p>The developer who has deliberately avoided that shift has made a choice and chosen the ceiling that comes with it: the queries they can't optimize because they can't read an execution plan, the schema they can't fully utilize because they don't know what an index covers, the race condition they can't see because they don't think in transactions.</p>\n<hr />\n<h2 id=\"the-generated-sql-argument\">The Generated SQL Argument</h2>\n<p>The objection I hear most often is: &quot;but I don't want to write the same SELECT and UPDATE boilerplate for every table.&quot; That's a legitimate objection, and it has a legitimate answer that isn't an ORM.</p>\n<p>reepolee generates the SQL functions for every resource directly from the schema. <code>get_record_by_id</code>, <code>create_record</code>, <code>update_record</code>, <code>search_records</code> - all of it, written in plain SQL using Bun's native tagged template literals, parameterized correctly, typed from the schema. No ORM. No query builder. No translation layer.</p>\n<pre><code class=\"language-ts\">const records = await db`SELECT * FROM users WHERE id = ${id} LIMIT 1`;\n</code></pre>\n<p>That is the query and that is what runs, so when it is slow you look at the query, when it does the wrong thing you read the query, when you need to add a condition you add it to the query, and there is nothing between you and the database but the driver.</p>\n<p>The consistency argument that ORMs usually win on - &quot;at least the boilerplate is handled the same way everywhere&quot; - is handled by the generator instead. The generator writes correct SQL once, in a pattern your team reviewed, and applies it to every resource. You get the consistency without the abstraction.</p>\n<hr />\n<h2 id=\"what-fluency-actually-looks-like\">What Fluency Actually Looks Like</h2>\n<p>A developer who is fluent in SQL thinks differently about data problems. When they need the last order per customer, they reach for a window function rather than fetching everything and sorting in application code. When they need to count and filter in one pass, they use a subquery. When they need to enforce that two writes are atomic, they open a transaction explicitly because they know what that means and why it matters.</p>\n<p>They can read an execution plan and understand why a query is slow. They know the difference between an index scan and a full table scan, and what the data volume at which that distinction starts to hurt. They know what a covering index is and when to add one. They know that <code>LIKE '%term%'</code> won't use an index and what the alternatives are.</p>\n<p>None of this requires being a DBA, but it does require having spent real time learning the system rather than learning the wrapper around it.</p>\n<hr />\n<h2 id=\"a-quiet-challenge\">A Quiet Challenge</h2>\n<p>If you've been working with relational databases for a few years entirely through an ORM, I'd suggest spending a week without one. Write the queries yourself. Read what they produce. Run EXPLAIN on the slow ones. Think about what the database is doing, not just what your application code says.</p>\n<p>You might find it uncomfortable at first. That discomfort is information. It's the gap between where you are and where the system actually lives. Most developers who cross that gap don't go back - not because raw SQL is more pleasant to write, but because understanding the system you depend on turns out to be more useful than having a comfortable wrapper around it.</p>\n<p>The database has been there since before JavaScript ran on the server, and it will be there after. It has its own rules, its own language, its own performance model. Learning them is not optional work for the truly curious - it is the baseline competency of a developer who works with data and wants to understand what they're doing.</p>\n<p>JavaScript runs on the server now. It didn't make the database a JavaScript object.</p>\n",
      "date_published": "2026-06-20T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/tools-that-work-for-you/",
      "url": "https://www.reepolee.com/engineering-notes/tools-that-work-for-you/",
      "title": "Use the Tools That Work for You",
      "summary": "On choosing tools by what makes you productive, not by what the community has decided signals the right kind of developer.",
      "content_html": "<p>I have two Macs on my desk, and I do most of my development work on Windows, although I have tried more than once to shift the balance and become the developer who reaches for the Mac first, because the ecosystem around web development leans that way, because the terminal is better integrated, because it is what most of the people I respect seem to use. It never stuck, because on Windows I am faster, the keyboard shortcuts are in my fingers, the tools I have relied on for years are there, some of them don't exist on macOS at all, and the software I ship doesn't know the difference.</p>\n<hr />\n<h2 id=\"the-hierarchy-nobody-elected\">The Hierarchy Nobody Elected</h2>\n<p>There is an unofficial ranking in developer culture that assigns credibility based on what you use. Linux at the top, macOS acceptable, Windows tolerated but suspicious. SQLite for quick things, PostgreSQL for real things, MySQL if you don't know better. Vim or at least something terminal-based; a graphical editor is fine but you should feel mildly apologetic about it.</p>\n<p>Nobody voted on this. Nobody demonstrated that it produces better software. It evolved as a set of social signals in a community that, like all communities, developed ways of marking who belongs and who doesn't. The signals feel technical but they aren't. They are aesthetic preferences that accumulated enough repetition to start sounding like engineering wisdom.</p>\n<blockquote>\n<p>The best OS is the one you think in. The best editor is the one you don't notice. The best database is the one that fits the problem.</p>\n</blockquote>\n<hr />\n<h2 id=\"what-productivity-actually-looks-like\">What Productivity Actually Looks Like</h2>\n<p>Speed in a tool is not about the tool being fast. It is about you not having to think about the tool. The keyboard shortcut that fires without a conscious decision. The workflow that runs without friction because you have run it a thousand times. The mental model of the environment that lets you navigate without consulting documentation.</p>\n<p>That kind of fluency takes time to build, and it does not transfer cleanly between environments. A developer who has spent years in one OS, one editor, one terminal is not being stubborn when they are slower on a different one - they are experiencing a real cost, and ignoring that cost in the name of doing things the right way is a trade that only makes sense if &quot;the right way&quot; produces meaningfully better outcomes.</p>\n<p>For most tools and most workloads, it doesn't. The application you build on Windows is the same application. The query you write in one editor runs the same way. The database you reach for because you know it well enough to optimize it is a better choice than the one you reach for because the community approves of it.</p>\n<hr />\n<h2 id=\"the-cases-where-it-does-matter\">The Cases Where It Does Matter</h2>\n<p>This is not an argument for never changing tools. There are real cases where the choice of OS or database or editor has meaningful technical consequences - deployment targets, native API requirements, performance characteristics at scale, team consistency, licensing constraints. When those cases arise, they are worth evaluating on their actual merits.</p>\n<p>The problem is applying that evaluation to every tool decision, including the ones where it doesn't matter, and arriving at a preference disguised as a technical requirement. &quot;We should use this database because it's what serious projects use&quot; is not an engineering argument. &quot;We should use this database because our data model benefits from its specific indexing behavior and we have the operational experience to run it&quot; is.</p>\n<p>Most tool choices for most projects fall into the first category more often than developers admit.</p>\n<hr />\n<h2 id=\"what-i-actually-do\">What I Actually Do</h2>\n<p>Windows for development. VS Code as the editor. Bun as the runtime - which at this point is less a runtime and more the platform the application is built for. MySQL and SQLite depending on the project, both of which I know well enough to not need an ORM to stand between me and what the database is doing. HeidiSQL for database work - open source, fast, genuinely good, and one of those tools I have been using and recommending since before you learned to spell influencer. Chrome - and I will not apologize for it. There is no shortage of opinion about Chrome's privacy defaults, and some of it is fair, but I have configured it to fit how I work and I am not going to pretend the developer tooling isn't exceptional. For building and debugging web applications it remains the tool I reach for, and the noise around it has never changed that. And a terminal that, if I am being direct about it, I prefer to what macOS ships or what iTerm2 offers - Windows Terminal with modern coreutils is a genuinely good tool that the community underestimates because the narrative about Windows as a development platform was written before Microsoft started taking the terminal seriously. It has been taken seriously for a while now.</p>\n<p>None of this is a recommendation. It is what has accumulated over a long career into a setup where I spend my time writing software rather than managing the environment I write it in. Someone else's version of that looks different, and as long as they are shipping good work, the difference doesn't matter. If you judge and dismiss me by my tools, that is your loss - you passed on the work to have an opinion about the environment it was built in.</p>\n<p>The goal is the software, and the tools are in service of that goal, so choose them based on what makes you productive and not on what makes you legible to a community whose approval you never needed in the first place.</p>\n",
      "date_published": "2026-06-20T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/use-the-right-tool/",
      "url": "https://www.reepolee.com/engineering-notes/use-the-right-tool/",
      "title": "Use the Right Tool",
      "summary": "On enforcing a modern browser baseline for internal applications, the real cost of backwards compatibility, and why the browser your staff uses is a business decision, not a technical given.",
      "content_html": "<p>This post is specifically about internal applications - the member portal, the staff scheduling tool, the shift rota, the grant management system. Software used by a known, bounded set of people who work for or with the organization. Not a public website that anyone on the internet might open on any device. That distinction matters for everything that follows.</p>\n<p>Defining a modern browser baseline for this kind of application is an act of simplification. Not a compromise, not a limitation imposed on users - a deliberate decision to remove an entire class of complexity from the codebase so that what remains is clean, maintainable, and honest about what it requires. Every polyfill you don't write, every compatibility workaround you don't add, every conditional branch you don't test is code that never existed and never needed to. That is the argument. The rest is explaining why the objections don't hold.</p>\n<p>There is a category of decision that every organization makes without thinking of it as a decision: the tools the people who use your software are allowed to bring to the table. You specify the server, the database, the runtime. You have opinions about the editor your team uses and the node version that runs in CI. And then, somewhere between the architecture diagram and the first pull request, the question of which browsers you support gets answered with &quot;all of them&quot; - and nobody pushes back, because pushing back sounds like you don't care about users. I want to push back on that assumption directly, because the browser is the same category of decision as everything else and treating it as a given rather than a choice has a real cost that accumulates with every feature you add.</p>\n<hr />\n<h2 id=\"every-other-tool-is-a-decision\">Every Other Tool Is a Decision</h2>\n<p>When an organization chooses office space, they don't ask the employees to vote on the building - they evaluate the options, pick one, and the employees work there. When they choose a cloud provider, they don't survey the staff. When they buy furniture, replace the coffee machine, pick the accounting software, these are business decisions made by the people running the organization based on what the organization needs, not on what any individual preference dictates, and the browser belongs in exactly that category.</p>\n<p>The people who use your software are bringing a tool to the interaction. You have every right to specify what that tool should be. Not arbitrarily - there are good reasons to choose a baseline, and the reasoning should be documented and explained to users who ask. But the idea that supporting every version of every browser back to whenever is a moral obligation rather than a technical choice made by default is one that deserves examination.</p>\n<p>A modern browser is not a luxury. It is a free, automatic update that every major vendor has been pushing silently in the background for years. The staff member on a five-year-old browser version is almost certainly there by policy, by neglect, or by an IT department that locked updates in 2019 and moved on. Those are real scenarios, and they have real solutions - a conversation with IT, a policy update, a machine refresh. None of them require you to write the application for the oldest denominator in the room.</p>\n<hr />\n<h2 id=\"what-backwards-compatibility-actually-costs\">What Backwards Compatibility Actually Costs</h2>\n<p>The polyfill exists because a browser doesn't support something. The workaround exists because a CSS property behaves differently in an older rendering engine. The test case exists because the layout breaks on a version that nobody on the team has installed. The conditional exists because a JavaScript API arrived in version X and you need to handle the case where it hasn't. All of this is code that wouldn't exist if you had stated a baseline from the start.</p>\n<p>This is not a small cost. It is cumulative, invisible, and paid continuously. Every new feature you build gets weighted down by the obligation to make it work somewhere it was never designed to work. Every developer on the team carries a mental model of what they can and cannot use. Every CSS trick that would simplify the layout gets set aside because it doesn't have the coverage numbers.</p>\n<p>And then there is the testing. A bug that only reproduces on an old browser version in a specific OS combination is a bug that takes a disproportionate amount of time to find, diagnose, and fix - and fixing it usually involves adding more special-case code that will need to be maintained long after the browser in question has a single-digit market share.</p>\n<p>The cost is borne by the developers and the organization. The user with the old browser pays nothing for the accommodation.</p>\n<hr />\n<h2 id=\"security-is-not-a-side-argument\">Security Is Not a Side Argument</h2>\n<p>Old browsers do not receive security patches. This is not a matter of policy or priority - it is the natural end state of software that has stopped being actively maintained. A user connecting to your application on an unpatched browser is a user whose traffic, session, and credentials are handled by software with known, publicly documented vulnerabilities.</p>\n<p>You are not doing that user a favor by continuing to support their environment. You are validating a choice that puts them at risk and, in some configurations, puts the application and its other users at risk alongside them. A modern baseline is not just a developer convenience. It is a security posture.</p>\n<hr />\n<h2 id=\"what-modern-baseline-means-in-practice\">What &quot;Modern Baseline&quot; Means in Practice</h2>\n<p>This is not about using experimental APIs that shipped last Tuesday. A modern baseline means the last two versions of Chromium-based browsers, Firefox, and Safari - a definition that covers the overwhelming majority of devices in any managed or semi-managed organizational environment. For internal applications used by staff on organization-issued or personally maintained hardware, the number of people this leaves out is effectively zero, and those people have a direct line to someone who can help them update.</p>\n<p>The features that live inside that baseline are not exotic. CSS Grid. Custom properties. Native form validation. The Fetch API. ES modules. Intersection Observer. Web components. These are things that have been stable for years, that work consistently across the covered browsers, and that allow you to write clean, maintainable, readable code without a build pipeline to smooth over the gaps.</p>\n<blockquote>\n<p>The features you're polyfilling today were finalized before some of your junior developers finished university.</p>\n</blockquote>\n<hr />\n<h2 id=\"you-equip-the-staff-not-the-other-way-around\">You Equip the Staff, Not the Other Way Around</h2>\n<p>A useful way to think about this: a company equips its staff with the tools needed to do the job. A new employee doesn't bring their own chair and expect the floor plan to move around it. The browser is a tool. For an internal application, the organization gets to specify which one.</p>\n<p>Your application is the product. You define what it requires. A staff member on an unsupported browser gets a clear message: this application requires a current browser, here is the link, IT can help. What you do not do is rewrite the application around a tool that creates more problems than the staff member it belongs to.</p>\n<hr />\n<h2 id=\"reepolees-position\">Reepolee's Position</h2>\n<p>Reepolee generates applications that assume a modern browser baseline. The templates use modern CSS, native browser APIs, and ES module syntax. There are no polyfill layers by default, no compatibility shims, no conditional code paths for rendering engines that haven't been current since before the project existed.</p>\n<p>This is a deliberate choice and a constraint that has consequences: it means the generated code is clean, readable, and does not carry the weight of a decade of browser compatibility workarounds. It means a developer reading a template can use what the browser natively offers without asking whether it's safe. It means the CSS does what CSS is designed to do, not what CSS had to do before the specification caught up.</p>\n<p>Polyfills can be added - but that becomes a conscious, explicit decision for a specific feature that needs it, not a default layer applied to everything. Declarative Partial Updates with streaming is one such case: a useful capability that sits ahead of full cross-browser native support, worth polyfilling for the applications that need it, and easy to add precisely because the baseline is clean enough that one targeted addition doesn't disappear into noise.</p>\n<p>The direction Reepolee is moving in is toward APIs that TC39 is actively exploring and confirming - the standards process, not the framework cycle. That is a different compass than following what is popular in the ecosystem this month. Standards move slowly and deliberately, which means the code written against them tends to stay correct longer, require less rework, and carry less baggage than code written against abstractions that the ecosystem will quietly deprecate in three years.</p>\n<p>The applications Reepolee is built for - internal tools, booking systems, volunteer portals, staff-facing administration panels - are used by people on organization-managed or personally maintained devices. Those people can be asked to use a current browser. In most cases they already are. The edge cases are manageable, and the right way to manage them is a polite notice explaining the requirement, not a codebase weighted down by trying to serve everyone at once.</p>\n<p>Define your baseline, state it clearly in the project documentation, and build to it without apology, because the application you ship will be cleaner and more maintainable for having made that decision explicitly rather than letting it happen by default.</p>\n",
      "date_published": "2026-06-20T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/quality-does-not-matter-until-it-does/",
      "url": "https://www.reepolee.com/engineering-notes/quality-does-not-matter-until-it-does/",
      "title": "Quality Doesn't Matter ... Until It Does",
      "summary": "Why quality standards must differ between social apps and enterprise software - and how investor pressure, low barriers to entry, and AI are driving a visible decline in the software that actually runs businesses.",
      "content_html": "<p>There is a conversation happening right now in every engineering team, every startup, every enterprise boardroom, and it goes something like: <em>&quot;Just use AI to write the code, ship it faster, iterate later.&quot;</em> I understand the appeal. If you've been around long enough, you've watched silver bullets come and go - CASE tools in the eighties, RAD in the nineties, agile as a magic word in the two-thousands - and every time, the industry convinced itself the new thing would let us move faster without paying for it. We're doing it again, and this time the stakes are higher for a certain class of applications.</p>\n<p>Let me be direct about what I'm seeing: the quality of software being shipped has been visibly, measurably, embarrassingly declining for the better part of a decade. AI did not cause that. It is the latest and most powerful accelerant of something already well underway.</p>\n<p>The real drivers started earlier. A long run of cheap capital and investor appetite for hypergrowth created enormous pressure to ship fast and grow at any cost, with quality treated as something to worry about after the next funding round. At the same time, the barrier to entering the industry dropped to nearly zero - not a bad thing in itself, but it meant large numbers of people began writing production code without the foundational education or apprenticeship this craft historically required. It became easy and cheap to declare yourself a developer, get hired as one, and ship code into systems real businesses depend on, without ever being seriously exposed to the idea that correctness is a discipline rather than a feeling.</p>\n<p>Velocity pressure from above and diluted craft standards from within had already been eroding quality for years before any AI tool arrived. AI took those conditions and added rocket fuel, applying the same pressure and the same reduced standards to a process that produces ten times the code in the same time. The volume of unreviewed, under-tested, contextually inappropriate code being merged into production has climbed sharply, and the consequences are starting to show. We handed a genuinely useful tool to an industry that was already cutting too many corners, and told everyone the output was good enough to ship.</p>\n<hr />\n<h2 id=\"the-low-stakes-majority-nobody-talks-about\">The Low-Stakes Majority Nobody Talks About</h2>\n<p>Here is something the tech press rarely acknowledges: a very large portion of the web runs under a completely different failure model than enterprise and business software, and almost everything we learned to accept about software quality in the last fifteen years came from watching that portion and wrongly generalizing its lessons to domains where they don't belong.</p>\n<p>The examples are everywhere once you look. On social media, a like count that's wrong for thirty seconds, a dropped notification, a timeline that skips three hours - someone shrugs and refreshes. On a marketing or landing page, a slow hero image or a typo in a form's error message gets noticed Monday and fixed Tuesday, and the world continues. On content and publishing sites, a missing image or a stale search result sends the reader to another tab; no data is lost, no obligation broken. Streaming platforms ship hundreds of A/B variants knowing some won't work perfectly, because shipping fast and fixing fast genuinely beats getting it right the first time - for them. And in gamification - leaderboards, badges, streak counters - a badge that shows up an hour late is, at worst, mildly annoying.</p>\n<p>What do these categories have in common? They deal in attention and engagement, not transactions and commitments. The user is browsing, reacting, exploring. Nothing they do creates a binding obligation, transfers real money, alters a legal record, or changes the physical stock in a warehouse. When something is wrong, they ignore it, refresh, or wait, and the wrongness becomes irrelevant.</p>\n<blockquote>\n<p>The defining question is not how many users you have. It is whether your system deals in attention and engagement, or in transactions and commitments.</p>\n</blockquote>\n<p>This is not an accident of culture. These platforms were designed from the start around the idea that approximate correctness is acceptable, because for them it genuinely is. The CAP theorem debates, the BASE-versus-ACID wars, eventual consistency as a philosophy - all of it emerged from large-scale social and e-commerce platforms solving problems at a scale where perfect consistency was impossible and inconsistency was tolerable. They built brilliant systems for their problems. Then the rest of the industry looked at what they built, saw that it was fast and scalable and used cool technology, and decided everyone should build that way.</p>\n<p>And now here we are, using AI to generate code that inherits all of those assumptions - &quot;ship it and fix it later,&quot; &quot;move fast and break things,&quot; &quot;good enough is good enough&quot; - and deploying it into contexts where those assumptions are catastrophically wrong.</p>\n<hr />\n<h2 id=\"what-happens-when-a-business-application-drops-the-ball\">What Happens When a Business Application Drops the Ball</h2>\n<p>Let me tell you what &quot;dropping the ball&quot; looks like in the applications that actually run the economy - because a lot of younger developers have never had to sit across a table from a furious CFO and explain why the software did something wrong.</p>\n<p>An invoice goes out for the wrong amount. Somewhere in the payment logic, a rounding function behaved differently in a currency-conversion edge case the generated code never considered, because the model was trained on millions of examples that mostly worked and had no reason to know your business has contractual rounding rules negotiated with specific large clients. The client notices. They call. Now you have a dispute that takes three weeks to resolve, and during those weeks the relationship frays, the account manager is stressed, finance is reconciling by hand, and legal is reading the contract for a penalty clause - all because one line rounded the wrong way.</p>\n<p>Or consider inventory. A warehouse system has a subtle concurrency bug in its stock-reservation logic - nothing exotic, a classic race condition. Two orders land at almost the same instant for the last unit. Both succeed. Both customers get a confirmation. You've oversold by one, which means one customer won't receive what they paid for. They get angry, they leave a review, they may never come back. At scale - and these bugs do happen at scale - the financial and reputational damage is real and lasting.</p>\n<p>Or healthcare, where I have colleagues working on electronic health records and clinical decision support. I won't detail what &quot;dropping the ball&quot; means there, because the consequences run from regulatory penalties to patient harm, and anyone reasonable understands that a wrong drug-dosage recommendation is not the same category of problem as a wrong movie recommendation.</p>\n<p>These aren't theoretical. They're the failures I've watched happen throughout my career, long before AI - but AI has sharply accelerated the conditions that make them likely, because it makes it so easy to produce code that looks correct, passes casual review, and behaves correctly in ninety-nine percent of cases while failing catastrophically in the one percent that matters most.</p>\n<hr />\n<h2 id=\"how-ai-and-llms-are-reshaping-degrading-software-quality\">How AI and LLMs Are Reshaping (Degrading?) Software Quality</h2>\n<p>I want to be fair here, because I am not one of those people who thinks AI tools are useless or that we should pretend they do not exist. They are genuinely useful. I use them myself. The problem is not the tools - the problem is how we are integrating them into our development culture and what assumptions we are implicitly accepting when we do.</p>\n<p>Large language models are, by their nature, pattern-matching systems trained on the aggregate of existing code. That makes them very good at producing code that looks like the code most commonly written for a problem, and much less reliable when the correct solution is uncommon: when the domain has unusual constraints that rarely appear in training data, when the edge cases are subtle and domain-specific, or when correctness depends on business rules that live only in someone's head or in a contract that was never digitized.</p>\n<p>The whole value of custom business software over off-the-shelf is that it encodes those specifics - the exceptions, the special cases, the regulatory requirements, the rules accumulated over years of operation. And that is exactly where the model is weakest. AI generates plausible median code - correct for the most common version of your problem. Business software is defined precisely by its exceptions. That is the gap.</p>\n<p>There is also a deeper cultural problem. Because AI makes code so cheap to produce, the pressure - from management, from investors, from the competitive environment - is to use that speed without paying for it with proportional increases in review rigor, testing depth, and architectural thinking. Velocity goes up; the quality gates don't. More code ships faster with less scrutiny, and even if the defect rate per line holds steady, the defect rate per unit of time climbs because so many more lines are shipping.</p>\n<p>I watch teams slide from carefully reviewing every pull request to merging on a glance, and it is genuinely alarming when it happens in codebases that process financial transactions or manage medical records. Peer review, for all its friction, is one of the most important quality mechanisms we have, and we are quietly dismantling it in the name of velocity.</p>\n<blockquote>\n<p>&quot;AI wrote it, it looks fine, merge it&quot; is not a code review. It is an abdication of one.</p>\n</blockquote>\n<hr />\n<h2 id=\"there-is-a-right-kind-of-code-generation-and-it-is-not-ai\">There Is a Right Kind of Code Generation, and It Is Not AI</h2>\n<p>Here is something that gets lost in the current AI enthusiasm, and it matters enormously for high-stakes software: code generation has existed for decades, it is genuinely useful, and the good kind of it works on completely different principles than what LLMs do. Conflating the two is one of the more consequential category errors that our industry is currently making.</p>\n<p>Consider what a proper domain-aware generator does. You give it your database schema - the real structure of your data, with your naming conventions, your column types, your relationships and constraints - and it produces, deterministically, a full stack of consistent artifacts: route handlers, SQL queries, form templates, validation schemas, translation keys, type definitions. Same input, same output, every time. No probability distribution, no sampling, no plausible median - just your schema in and correct, consistent code out, according to rules you wrote, reviewed, and trust because you understand them completely.</p>\n<p>The key word is <em>consistent</em>. In a business application, inconsistency is a silent killer. When ten developers over three years each write their own version of the CRUD pattern, you get ten approaches to validation, ten ways of handling database errors, ten conventions for delete confirmation, ten assumptions about what a half-failed form submission does. None of these is catastrophic alone. Together they produce a codebase that is impossible to reason about completely, impossible to audit confidently, and full of edge-case variations that only surface under production conditions nobody tested.</p>\n<p>A well-designed generator eliminates that entire class of problem by construction. You review the generator once, as a team - its patterns, its SQL concurrency handling, its validation, its error handling - and every resource it produces inherits those approved patterns identically. Find a bug in the pattern, a rounding issue in a currency field, a missing uniqueness check, a transaction that should be atomic and isn't, and you fix it in one place and regenerate. With AI-generated code, that same logical bug shows up in slightly different forms across dozens of files, each found and fixed by hand.</p>\n<p>This is precisely the kind of system I've been building with Reepolee: introspect the schema as the single source of truth, and from it flows the route handlers, the SQL layer, the form templates, the server-side validation, the translation files, even the navigation. The generator knows a decimal column in a financial context needs specific handling, that a foreign key implies particular join patterns, that a timestamp has display and filtering requirements. That knowledge is reviewed once and applied everywhere. It's dramatically faster than writing by hand without trading away correctness, because correctness is baked into the generator rather than left to a model's probabilistic judgment at generation time.</p>\n<p>That is the distinction worth keeping clearly in mind: automated generation is deterministic, auditable, and an executable expression of your architectural decisions. AI generation is a probabilistic best guess at what code usually looks like for a problem that superficially resembles yours. For the boilerplate of business software - and there is a lot of it - automated generation is almost always the better choice.</p>\n<hr />\n<h2 id=\"the-false-equivalence-of-all-software-is-the-same\">The False Equivalence of &quot;All Software Is the Same&quot;</h2>\n<p>One of the most damaging moves of the last decade has been the gradual erasure of the distinctions between categories of software. We drifted into deciding - without anyone explicitly choosing it - that all software should be built, deployed, maintained, and measured the same way.</p>\n<p>Five minutes of thought shows how wrong that is. A social media feature and a bank transfer are not the same kind of software. A feed algorithm and a medical dosing calculator are not the same kind of software. A gaming leaderboard and an inventory system are not the same kind of software. They are different artifacts with different correctness requirements, failure consequences, regulatory contexts, and user expectations.</p>\n<p>&quot;Move fast and break things&quot; makes sense for an early consumer product chasing product-market fit, where the cost of breaking things is low. It makes roughly zero sense for software that processes payroll, manages supply chains, handles insurance claims, or operates industrial equipment. Taking a philosophy built for one extreme end of the risk spectrum and applying it across the whole spectrum is, in retrospect, one of the more puzzling failures of collective reasoning I've witnessed in my career.</p>\n<p>I'm not arguing that we should slow down everywhere or reject AI tools. I'm arguing for a return to contextual judgment - the kind that says: this is a social media feature, so reasonable velocity and tolerable error rates are fine here; this processes financial transactions on behalf of businesses, so it needs different standards, different testing, different review, and a more careful use of AI assistance.</p>\n<hr />\n<h2 id=\"the-enterprise-and-e-commerce-developers-responsibility\">The Enterprise and E-commerce Developer's Responsibility</h2>\n<p>If you work on software that handles money, manages inventory, stores medical information, calculates taxes, processes legal documents, generates invoices, or does anything else where a mistake has direct, measurable consequences for real people - I'm speaking to you, and I want you to think hard about what the current environment is asking you to accept.</p>\n<p>You're being asked to ship faster, lean on generated code more liberally, cut review cycles, and trust that tests will catch the rest. Some of that pressure is legitimate: the competition is real, the need for velocity is real, and these tools genuinely help you do good work faster when used with care.</p>\n<p>Someone has to be the person in the room who remembers what the consequences of failure look like in the domain - the one who insists on real test coverage for financial calculations, who demands that inventory logic be reviewed by someone who understands the concurrency model, who requires that any generated code touching money or legal obligations be scrutinized with the rigor we always applied before AI existed. Pushing back, constructively and professionally, when the velocity being asked for is incompatible with the quality the domain requires is not pessimism or Ludditism. It's professionalism. Surgeons use far better tools than they did forty years ago, and the standard for an acceptable surgical outcome did not drop because the tools improved. Neither should ours.</p>\n<hr />\n<h2 id=\"what-good-actually-looks-like-in-high-stakes-software\">What Good Actually Looks Like in High-Stakes Software</h2>\n<p>In the interest of being constructive rather than just critical, let me describe what I believe good practice looks like for enterprise, business, and e-commerce software development in the current AI-augmented environment.</p>\n<p>It means drawing a clear line between the parts of your application that are structurally repetitive and the parts that encode real business logic, and treating them differently. The repetitive parts - CRUD, SQL, form templates, validation schemas, API endpoints - belong to a deterministic generator you can audit once and trust everywhere. The business logic - the pricing engine, the inventory reservation, the rules specific to your contracts and regulatory environment - is where human authorship and human review are irreplaceable, and where you should be most skeptical of AI, precisely because that's where it's most likely to be wrong about the details that matter most.</p>\n<p>It means keeping - and probably increasing - review rigor on any code that touches financial, medical, legal, or inventory-critical paths. AI generating the code does not mean AI reviewed it. And reviewing generated code is underestimated: it is a different and often more exhausting act than writing it yourself. When you write, the thinking happens before the first line - how the piece fits the architecture, which patterns the codebase uses, which edge cases your domain requires. When you review generated code, none of that prior thinking is present; you're handed a finished artifact and asked to reconstruct the model behind it and judge whether that model fit your context - harder still because generated code tends to be more verbose than experienced human code, giving subtle incorrectness more places to hide.</p>\n<p>It means investing seriously in automated tests of business logic at exactly the boundaries and edge cases most likely to be wrong. Tests for financial calculations should cover every rounding rule, every currency combination, every contractual exception. It's tedious, and it's the only reliable way to know your code is correct.</p>\n<p>It means building observability around correctness, not just performance. Knowing immediately when invoice totals drift, when inventory counts go impossible, when transaction reconciliation fails - the faster you catch a correctness failure, the less it costs you.</p>\n<p>And perhaps most importantly, it means organizational cultures in which raising quality concerns is respected and supported rather than treated as an obstacle to velocity. The developer who says &quot;I think we need another week to get this financial logic right&quot; is not being obstructionist - they are being professional. The organization that treats that developer as a problem to be overcome rather than a valuable signal to be listened to is building toward a very expensive incident.</p>\n<hr />\n<h2 id=\"we-have-seen-this-before\">We Have Seen This Before</h2>\n<p>This is not the first time the industry has done this, and probably not the last. Every major transition produces a wave of enthusiasm in which people overgeneralize the new capability, apply it where it doesn't belong, ship things that fail, and eventually settle into a mature understanding of where it's genuinely useful and where the old disciplines still apply. It happened with object-oriented programming, client-server, the web, agile, cloud. Each time, the early enthusiasm promised the new approach would eliminate problems it could not actually eliminate, followed by painful learning, followed by a more nuanced synthesis.</p>\n<p>We are in the middle of that process right now with AI-assisted development. The enthusiasm is high, the generalization is wide, and the painful learning has begun in some domains but has not yet reached the broader consciousness of the industry. The blog posts about AI-caused production incidents in serious business software are starting to appear. The retrospective analyses are beginning. The mature synthesis will come, as it always has.</p>\n<p>What I am hoping - and what this post is my small contribution toward - is that the painful learning period is shorter and less expensive this time, particularly for the organizations and users who can least afford to pay the price of it. A social media company that ships a bad feature and rolls it back has lost some user engagement. A healthcare company that ships incorrect medication logic has potentially harmed patients. The asymmetry of consequences should be driving asymmetric standards, and right now, in too many places, it is not.</p>\n<hr />\n<h2 id=\"closing-the-price-of-quality-is-not-optional\">Closing: The Price of Quality Is Not Optional</h2>\n<p>There is a tempting argument I hear often: the market will sort this out, organizations that ship bad software in high-stakes domains will lose to ones that ship better, and the incentives will pull quality up on their own. I've watched markets try to sort out software quality for years, and I'm not convinced the mechanism works reliably or quickly enough to protect the people who depend on this software to work correctly.</p>\n<p>The quality of software that runs businesses, handles money, manages inventory, and serves patients is not just a competitive differentiator - it is a professional and ethical obligation. The developers and architects who build that software have a responsibility that goes beyond shipping fast and iterating later. We carry the responsibility of knowing what &quot;incorrect&quot; looks like in our domain and refusing to ship it, regardless of the pressure.</p>\n<p>AI tools are not going away, and they should not go away - they are genuinely useful. But useful tools in the hands of professionals require the professionals to maintain their professional standards. A faster saw does not lower the standards for carpentry. A faster code generator should not lower the standards for software that real businesses depend on to operate correctly.</p>\n<p>Quality doesn't matter - until it does. And in business software, in enterprise software, in e-commerce, in finance, in healthcare, in logistics, in any domain where incorrect software causes real harm to real people and real organizations - it always matters. It has always mattered. We just temporarily forgot that, and now it is time to remember.</p>\n",
      "date_published": "2026-06-16T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/db-is-permanent/",
      "url": "https://www.reepolee.com/engineering-notes/db-is-permanent/",
      "title": "The Database Is Probably Permanent",
      "summary": "Why most teams never replace their primary database engine, and why designing for portability costs more than it returns. Covers stored procedures, views, triggers, DDL tooling, codegen as an ORM alternative, and the generator pattern behind Reepolee.",
      "content_html": "<p>There is a piece of architectural advice that circulates through engineering teams like received wisdom:</p>\n<blockquote>\n<p>&quot;Design your application so you can swap databases at any time.&quot;</p>\n</blockquote>\n<p>It sounds responsible and forward-thinking. For the vast majority of software projects it is a comfortable fiction - one that quietly consumes more engineering effort than it ever returns. A system nobody can change without three layers of indirection isn't well-designed, it's just well-defended. Early abstraction adds friction to the work that has to ship today, and that friction compounds long before any theoretical future benefit arrives. You pay for the option every day, in complexity, in slower delivery, and in features your database would have given you for free if you had let it.</p>\n<p>The decisions you make this quarter shape your life as a maintainer years from now, so the long game is worth understanding first.</p>\n<hr />\n<h2 id=\"how-rarely-do-companies-actually-switch\">How Rarely Do Companies Actually Switch?</h2>\n<p>What follows is drawn from the projects I've been part of rather than controlled study, and I want to be transparent about that because the honest framing matters here. These are patterns, not statistics.</p>\n<table>\n<thead>\n<tr><th>Scenario</th><th>How often it happens</th></tr>\n</thead>\n<tbody>\n<tr><td>Greenfield project stays on its original database indefinitely</td><td>The overwhelming majority</td></tr>\n<tr><td>Major version upgrades (e.g., PostgreSQL 14 → 17)</td><td>Very common</td></tr>\n<tr><td>Moving from self-hosted to a managed cloud service</td><td>Common</td></tr>\n<tr><td>Migrating within the same database family (e.g., MySQL → MariaDB)</td><td>Occasional</td></tr>\n<tr><td>Replacing the relational engine entirely (e.g., Oracle → PostgreSQL)</td><td>Uncommon</td></tr>\n<tr><td>Application genuinely designed so the database is swappable with no code changes</td><td>Extremely rare</td></tr>\n</tbody>\n</table>\n<p>When large organizations do replace their database engine, it is almost never because the team found something more attractive. It happens because of licensing costs, acquisitions, end-of-life products, or platform decisions made at the executive level - and it unfolds over months or years, with dedicated migration teams, parallel production runs, and staged cutovers. The application code is usually the easiest part.</p>\n<p>There's an obvious counter-argument: maybe companies don't switch because the abstraction worked so well nobody dared try. Sometimes, perhaps. But it doesn't change the math, because the teams carrying that abstraction overhead pay for it every single day whether or not the migration ever comes.</p>\n<hr />\n<h2 id=\"why-the-database-becomes-load-bearing-infrastructure\">Why the Database Becomes Load-Bearing Infrastructure</h2>\n<p>Here is how it tends to go in practice. On one project I worked on, the team committed to multi-database support from day one, so every query was held to the lowest common denominator: basic selects, simple joins, nothing the ORM couldn't generate identically for any backend. Anything harder got pushed up into the application layer. Sorting happened in memory. Aggregations were assembled in code. Queries the database could have resolved in a single round trip became five simple ones stitched together in the service layer, because a single expressive query felt like too much of a commitment. The database was never switched. The code that grew up around those constraints outlived their rationale by years, and every new engineer had to be told why things were done the hard way.</p>\n<p>This happens because the database does not stay a data store. Over time it becomes embedded in almost every layer of the system: SQL queries and the optimizations built around their actual execution plans in production; index design that emerged from real query patterns observed after launch; stored procedures and native functions that encapsulate business logic added under time pressure; transaction and locking semantics that application behavior has quietly come to depend on; extensions that are genuinely difficult to replicate; ORM dialect assumptions baked into hundreds of queries across years of releases; and the accumulated migration history that documents decisions nobody fully remembers.</p>\n<p>Even teams that start with clean ORM abstractions get pulled toward database-specific features over time - not out of carelessness, but because those features solve real problems and reduce complexity elsewhere. Using a capable tool well is not a failure of discipline.</p>\n<hr />\n<h2 id=\"when-portability-actually-matters\">When Portability Actually Matters</h2>\n<p>Before going further, this argument deserves a real boundary - because there are situations where supporting multiple databases is a genuine engineering requirement and not an architectural indulgence.</p>\n<p>If you are building an open-source library or framework, your users run whatever database their organization already has. Supporting PostgreSQL and MySQL is not over-engineering; it is the product. If you are building commercial software distributed to thousands of enterprise customers, those customers may be contractually tied to a specific engine before you ever speak to them. Portability is a sales requirement. And if you are building a developer tool or ORM itself, then obviously the point is abstraction.</p>\n<p>Outside these specific cases - and for the vast majority of product teams building software for their own users on infrastructure they control - the portability argument tends to collapse under the weight of its actual costs.</p>\n<hr />\n<h2 id=\"do-your-due-diligence-before-you-commit\">Do Your Due Diligence Before You Commit</h2>\n<p>The only moment when thinking carefully about database portability pays meaningful dividends is <em>before</em> you make the initial choice - not after you have built half a system on assumptions you are now second-guessing.</p>\n<p>That initial evaluation deserves genuine investment. Rather than generic advice about &quot;assessing ecosystem maturity,&quot; here are the specific questions worth answering before you commit.</p>\n<p>Can your team debug a slow query plan without looking it up? Operational familiarity under pressure matters more than benchmark numbers. A database your team knows well will outperform a theoretically superior one that nobody can tune.</p>\n<p>Does the database have a managed cloud offering from your preferred provider? Self-hosting a database well is a full-time operational responsibility. Knowing whether you can hand that off - and to whom - affects your total cost of ownership for years.</p>\n<p>What does an emergency failover look like? Walk through the actual procedure before you need it. If the answer is unclear or requires the vendor's professional services team, that is useful information.</p>\n<p>Has the technology absorbed your expected scale in comparable production environments? Case studies from organizations at roughly your order of magnitude are worth more than synthetic benchmarks.</p>\n<p>Notice that most of these questions are about your team, not the database. A technically superior engine is the wrong choice if nobody can operate, tune, or troubleshoot it at two in the morning.</p>\n<blockquote>\n<p>Evaluate seriously up front, write down the reasoning, then commit and use the thing fully.</p>\n</blockquote>\n<hr />\n<h2 id=\"once-a-system-is-battle-tested-change-becomes-a-business-risk\">Once a System Is Battle-Tested, Change Becomes a Business Risk</h2>\n<p>Architects learn this through experience rather than textbooks: once a system has run reliably long enough to accumulate real operational history, very few people anywhere in the organization are eager to replace one of its core components - and their reluctance is well-founded, not timid. By that point the database is not a line in an architecture document. It is part of a system that has survived real traffic, strange edge cases, incidents, compliance audits, and years of accumulated knowledge about how it behaves under load. It has earned institutional trust that no benchmark can replicate.</p>\n<p>Replacing it means spreading risk across the whole organization at once. Engineering managers worry about downtime windows, operations about data integrity during migration, product owners about feature delivery stalling for months, executives about business continuity and regulatory exposure. Even when migration promises real long-term benefits, it competes directly with shipping customer value, and the case for disrupting a system that is doing its job rarely becomes compelling enough to act on.</p>\n<hr />\n<h2 id=\"the-database-is-not-just-storage-use-all-of-it\">The Database Is Not Just Storage - Use All of It</h2>\n<p>The conventional advice says business logic belongs in the application layer and the database should be a dumb store. That advice is worth questioning seriously, because some of the most reliable and performant systems I have worked on treat that boundary very differently.</p>\n<p>Stored procedures, views, triggers, and functions are not legacy artifacts or architectural compromises from a less enlightened era. They are first-class tools that the database has been optimizing for decades, and they offer things the application layer cannot replicate without significant cost.</p>\n<p>Stored procedures execute logic where the data already lives, eliminating round trips and running inside the transaction boundary automatically - no extra infrastructure, no coordination overhead. Views create a stable abstraction layer within the database itself - one that can be versioned, permissioned, and composed without changing application code or redeploying anything. Triggers enforce data integrity and reactive behavior at the storage layer, where it cannot be bypassed by application bugs, direct database access, migration scripts run by a junior engineer at midnight, or any other path that skips the application entirely. Functions allow complex logic to be composed, reused, and even tested directly against the database without touching a deployment pipeline.</p>\n<p>The usual objection is that these live inside the database, invisible to anyone reading the application code, unreviewable in pull requests, undiscoverable unless you already know to look. That's valid, and it's a solved problem. DDL migration tools let you define every procedure, view, trigger, and function as a plain SQL file committed to the repository, applied in order, and tracked in history. Your database objects get code review, git blame, and a change log like everything else, and the database state at any point becomes reproducible from source - which quietly fixes onboarding, local development, and environment parity too.</p>\n<p>So if your database is permanent, treating it as a passive key-value store leaves performance, consistency, and reliability on the table. Data locality alone eliminates whole classes of latency problems that application-layer architectures then spend months solving with caching layers, background jobs, and eventual-consistency models that bring their own failure modes. And once your business logic lives in procedures and functions, the ORM portability question answers itself - you probably didn't need the ORM at all.</p>\n<hr />\n<h2 id=\"generate-for-the-database-you-chose-not-for-every-database\">Generate for the Database You Chose - Not for Every Database</h2>\n<p>Once you have genuinely committed to a database and started using its native capabilities, a fair question emerges: how do you keep the application code that talks to it clean and maintainable without reaching for an ORM?</p>\n<p>The generator pattern is the answer I keep coming back to. Instead of writing a single data access layer that tries to work across multiple databases at runtime, you generate application code that is specifically and deliberately written for the database you chose. That code lives in your repository, reads like normal application code, and goes through code review like everything else - but it is shaped around your actual database rather than a generic interface designed to paper over the differences between five of them.</p>\n<p>The abstraction is real. It just happens at generation time instead of at runtime. You write a definition of what your application needs; the generator produces typed, database-specific code that does exactly that - without the overhead, the magic methods, or the opaque query generation that makes debugging an ORM a special kind of miserable. You can read every line of what runs against your database, because you generated it.</p>\n<p>This is the architectural idea behind <a href=\"/reepolee\">Reepolee</a> - a codegen starter that takes your schema and generates the application-side data access layer tailored to your database of choice. No ORM, no runtime abstraction, no portability theater. The generated code knows exactly which database it is talking to and is written to use that knowledge, not hide it.</p>\n<hr />\n<h2 id=\"use-the-full-capability-of-the-tool-you-chose\">Use the Full Capability of the Tool You Chose</h2>\n<p>Choosing a mature, capable database and then deliberately avoiding its strengths to preserve a theoretical portability option is like buying a precision instrument and restricting yourself to what a cheaper general-purpose tool could have done. It is not caution. It is waste dressed up as discipline.</p>\n<p>Good engineering means using the capabilities of your tools in ways that are intentional, understood, and justified - while avoiding coupling that delivers no practical benefit. There is a meaningful difference between over-specialization, where application logic becomes entangled with database internals unnecessarily, and simply using native features that your database handles better than any abstraction layer can simulate.</p>\n<p>The goal is not a system that pretends to have no dependencies. The goal is a system whose dependencies are chosen deliberately, documented honestly, and used to their full potential.</p>\n<p>The question worth asking is not whether your application <em>can</em> switch databases. The better question is whether the cost of preserving that flexibility today - in complexity, in avoided features, in slower delivery, in daily friction - is genuinely justified by the realistic probability that you will actually need to use it. In most product teams, that question answers itself over time.</p>\n<p>Choose your database carefully. Ask the hard questions from the start. Use it fully once you decide. And stop optimizing for a migration that, in all likelihood, will never happen.</p>\n",
      "date_published": "2026-06-12T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/bun-sql-in-cli-s/",
      "url": "https://www.reepolee.com/engineering-notes/bun-sql-in-cli-s/",
      "title": "Why Bun's SQL Dies in CLI Scripts (and How to Fix It)",
      "summary": "How Bun's internal SQL connection pool interacts with the event loop in CLI scripts, why idle connections fail to keep the process alive, and two fixes: reserve() for transaction-scoped connection affinity, setInterval() for fire-and-forget scripts. Both disappear inside Bun.serve().",
      "content_html": "<p>I lost an hour to this one before the cause clicked, and I'm writing it down because the symptom is genuinely confusing: a migration script that ran two queries kept dropping the second one - sometimes silently, sometimes with a <code>Connection closed</code> error - even though the exact same code worked perfectly inside a <code>Bun.serve()</code> server. The code looked right. The queries were correct. And yet the second one simply didn't run.</p>\n<p>The difference isn't the SQL but the event loop, and once you understand that, the fix is immediate.</p>\n<hr />\n<h2 id=\"the-problem\">The Problem</h2>\n<p>Bun's native <code>sql</code> uses a <strong>connection pool</strong> internally, and the pool's idle connections don't register persistent I/O on the event loop. Long-lived handles like <code>Bun.serve()</code> keep the loop alive because they hold an open server socket, but an idle pool connection does not, which means that in a standalone script your first query runs and its promise resolves, the event loop sees no more pending I/O, the process exits, and the SQL connection is torn down mid-execution before the second query ever gets the chance to run.</p>\n<hr />\n<h2 id=\"the-fix\">The Fix</h2>\n<p>There are two clean options, and which one fits depends on what the script needs to do.</p>\n<p><strong>Option A - Keep the event loop alive with a dummy timer, then exit manually:</strong></p>\n<pre><code class=\"language-typescript\">import { sql } from &quot;bun&quot;;\n\nconst db = sql`mysql://user:pass@localhost:3306/mydb`;\n\nconst stay_alive = setInterval(() =&gt; {}, 2_147_483_647);\n\nasync function run() {\n    const users = await db`SELECT id, name FROM users`;\n    console.log(users);\n\n    const orders = await db`SELECT id, total FROM orders`;\n    console.log(orders);\n\n    clearInterval(stay_alive);\n    await db.end();\n    process.exit(0);\n}\n\nrun().catch((err) =&gt; {\n    console.error(err);\n    process.exit(1);\n});\n</code></pre>\n<p><strong>Option B - Use <code>sql.reserve()</code> to hold a dedicated, persistent connection:</strong></p>\n<pre><code class=\"language-typescript\">import { sql } from &quot;bun&quot;;\n\nconst db = sql`mysql://user:pass@localhost:3306/mydb`;\n\nasync function run() {\n    const conn = await db.reserve();\n\n    try {\n        const users = await conn`SELECT id, name FROM users`;\n        console.log(users);\n\n        const orders = await conn`SELECT id, total FROM orders`;\n        console.log(orders);\n    } finally {\n        conn.release();\n        await db.end();\n        process.exit(0);\n    }\n}\n\nrun();\n</code></pre>\n<hr />\n<p><code>reserve()</code> is the right call when you need transaction-level connection affinity, which is the case for anything wrapped in <code>BEGIN</code> / <code>COMMIT</code>, because it holds a single dedicated connection for the life of the block. The <code>setInterval</code> approach is simpler for fire-and-forget scripts that just run a few queries and quit, where you don't need that guarantee. Both workarounds become unnecessary the moment this code moves inside <code>Bun.serve()</code>, because the server socket holds the loop open for as long as the process runs and the problem simply doesn't arise.</p>\n",
      "date_published": "2026-06-08T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/dunning-kruger-mid-level-ai-team-lead/",
      "url": "https://www.reepolee.com/engineering-notes/dunning-kruger-mid-level-ai-team-lead/",
      "title": "The Dunning-Kruger Curve Has a Sweet Spot - and It's Hiring You Mid-Level Engineers",
      "summary": "On the engineering talent market, why mid-level engineers compound faster than the extremes, and how AI tools specifically reward developers who are competent enough to direct them but curious enough not to trust them blindly.",
      "content_html": "<p>There's a hiring pattern I keep running into, and I suspect it isn't an accident: the strongest engineering teams I've worked with aren't built on a stack of senior architects, and they aren't built on cheap juniors either. The engine, more often than not, is a small group of ambitious mid-level developers paired with one experienced lead and reasonably good tooling - and when I try to explain why that combination works so reliably, I always end up talking about the Dunning-Kruger curve and what it does to the cost of getting things shipped.</p>\n<hr />\n<h2 id=\"the-curve-briefly\">The Curve, Briefly</h2>\n<p>A junior engineer often underestimates the work. They can see the surface of a problem and assume the rest of it looks the same - the part they haven't reached yet, the edge cases hiding beneath a feature that seemed straightforward on the ticket, the production behaviors that only emerge under conditions nobody simulated. A senior engineer has been surprised by enough of those edges that they sometimes become cautious to the point of skepticism about anything that looks simple, which is its own kind of friction when the organization needs something to move.</p>\n<p>A strong mid-level engineer sits in the middle of that curve. Experienced enough to deliver. Humble enough to ask. Still moving. That balance is genuinely rare, and it's the part most teams undervalue until they've felt the absence of it on a deadline.</p>\n<hr />\n<h2 id=\"why-mid-level-often-works-best\">Why Mid-Level Often Works Best</h2>\n<p>A good mid-level developer has usually shipped enough features end-to-end to estimate honestly - which is a skill that looks trivial from the outside and turns out to be extremely valuable when you're trying to plan anything that takes more than a week. They've debugged a production issue at 11pm and emerged on the other side with a better understanding of how things actually behave versus how they were designed to behave. They've read other people's code, accepted that it was written by someone under constraints they didn't know about, and made peace with it rather than rewriting everything on principle.</p>\n<p>They know when to ask for help, and more importantly, they know which questions are worth asking and which ones they should spend another twenty minutes figuring out themselves before escalating. That judgment - about when to push through and when to surface something - is much harder to develop than any specific technical skill, and it's what separates a developer who generates forward motion from one who generates questions.</p>\n<p>A junior may still need a fair amount of guidance to move independently. A senior may be too strategic, or too expensive, for work that is mostly execution. A mid-level engineer, given a reasonably well-defined problem and a system that isn't fundamentally broken, often gives the best return - and that's before you factor in drive.</p>\n<hr />\n<h2 id=\"the-hidden-advantage-drive\">The Hidden Advantage: Drive</h2>\n<p>Capability is the part everyone talks about. Drive is the part that decides outcomes, and this is where mid-level engineers have a structural advantage that's easy to overlook.</p>\n<p>A lot of mid-level engineers are in a specific phase of their career: hungry to prove themselves, financially motivated, still trying to gain real ownership of systems and demonstrate that they can carry something significant from start to finish. They want harder problems, bigger responsibility, and they want the next title to mean something rather than being handed to them because enough time passed. That combination - baseline capability plus genuine hunger - creates momentum, and momentum is what separates &quot;we shipped&quot; from &quot;we're still scoping.&quot;</p>\n<p>Juniors are often still figuring out fundamentals and the invisible norms of how teams function, which limits how much that hunger can convert into output. Seniors frequently optimize for influence, stability, architecture, or sustainable pace - all reasonable, all valuable, and all different from raw shipping velocity. A strong mid-level engineer is usually still climbing aggressively, and because the floor of execution is already there, the ambition actually lands.</p>\n<hr />\n<h2 id=\"where-ai-changes-the-picture\">Where AI Changes the Picture</h2>\n<p>AI tooling makes a good mid-level engineer feel meaningfully stronger than their title suggests, and the reason is straightforward: the things AI removes from their day - boilerplate, first drafts, test scaffolding, documentation, the tedious parts of refactoring - are precisely the things that used to create a ceiling on how much a single person at that level could produce. Used well, AI buys back the hours that used to go into the parts of the job that weren't actually thinking, and it lets the thinking expand to fill that space.</p>\n<p>What it doesn't do is replace judgment. AI can produce code quickly, but it cannot tell you whether the architecture matches the product, whether the edge case matters in your specific domain, whether the security assumption is safe in your threat model, or whether the feature is worth building at all. Those questions are still human questions, and a mid-level developer with five years of experience can answer most of them - but not all of them, and not every time, and not under pressure without someone to pressure-check them against.</p>\n<p>That gap is where the team lead lives.</p>\n<hr />\n<h2 id=\"why-a-team-lead-still-matters\">Why a Team Lead Still Matters</h2>\n<p>An experienced lead gives a mid-level developer a frame - the context that doesn't show up on the ticket: what's actually important right now, what should be simplified, what should be delayed, what shouldn't be built at all, and what needs another set of eyes before it ships into a part of the system that's load-bearing. Without that frame, a driven mid-level engineer can produce a lot of very fast movement in directions that turn out to be wrong, which is an expensive way to learn something a good lead would have surfaced in a fifteen-minute conversation.</p>\n<p>AI helps with speed. A lead helps with direction. A mid-level developer in the middle of that provides the execution. The three together can produce work that is fast, clean, and pointed at the right thing - which is rarer than it sounds, and worth structuring a team around deliberately.</p>\n<hr />\n<h2 id=\"the-important-nuance\">The Important Nuance</h2>\n<p>None of this is an argument against hiring seniors. Great senior engineers create force multiplication - they prevent expensive mistakes, simplify systems before they become complicated, mentor the rest of the team, and reduce the long-term operational tax that everyone else has to pay. Their value is often indirect and almost always enormous, and teams that try to run without them tend to discover this the hard way.</p>\n<p>But a team that is <em>only</em> seniors tends to drift toward too much abstraction, too many meetings, and slower shipping - not because seniors are lazy, but because they're operating closer to the top of their personal curve, with less left to prove. A team that is <em>only</em> juniors struggles with consistency, prioritization, and production reliability. The healthiest teams have a mix, and in my experience, the day-to-day execution engine is most often a small group of motivated mid-level developers with good support around them.</p>\n<p>If the work is well-defined and the risk is moderate, that shape is usually the right one. If the work is ambiguous, strategic, or carries real downside risk, senior involvement becomes essential - usually upstream of the implementation, framing what should happen and why. And if you care about long-term resilience, juniors have to be part of the picture too: mid-levels don't grow from thin air.</p>\n<p>The best teams I've seen don't worship seniority. They put each level where it does the most work - and they're honest with themselves about what each level actually provides.</p>\n",
      "date_published": "2026-06-03T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/fail-loudly-env-vars/",
      "url": "https://www.reepolee.com/engineering-notes/fail-loudly-env-vars/",
      "title": "We Make Our App Fail Loudly on Missing Environment Variables",
      "summary": "On startup validation, silent fallbacks, and the principle that a process that won't start with bad config is safer than one that runs quietly with it. Covers Bun's env handling and a simple pattern for making misconfiguration impossible to miss.",
      "content_html": "<p>I have debugged this class of problem more times than I care to remember, and every single time the experience is the same: two hours of staring at logs that look completely reasonable, with no error, no stack trace, nothing obviously wrong - just behavior that doesn't make sense until you realize the application was quietly connecting to the wrong database, sending emails to nowhere, or charging a live payment processor while running what you believed was a local test. The ancestor of all of these is the same: a hardcoded fallback for an environment variable that was never set.</p>\n<p>So let me be direct, with some genuine frustration behind it: your application should refuse to start when a required environment variable is missing. Not fall back. Not log a warning and carry on. <em>Stop. Scream. Die.</em> Make it impossible to miss.</p>\n<hr />\n<h2 id=\"the-seductive-appeal-of-fallbacks\">The Seductive Appeal of Fallbacks</h2>\n<p>It feels responsible to write this:</p>\n<pre><code class=\"language-ts\">const DB_URL = process.env.DATABASE_URL ?? &quot;postgres://localhost:5432/myapp&quot;;\n</code></pre>\n<p>It feels like good defensive work - thoughtful, considerate of the poor soul who clones the repo and forgets to copy <code>.env.example</code>. And for truly optional configuration like feature flags, log verbosity, or request timeouts, a fallback is perfectly reasonable. But for anything that determines <em>where your application connects, who it talks to, and what it does with real data</em>, a fallback is a landmine with a friendly face.</p>\n<p>The problem is that fallbacks make wrong configurations look like right ones. Everything compiles, everything starts, requests flow through the system, and the only sign of trouble is buried in behavior rather than errors. By the time you notice, you may have sent a hundred welcome emails to <code>/dev/null</code>, written three hours of sessions to a SQLite file that gets wiped on every deploy, or done something irreversible to production data while running what you thought was a local test.</p>\n<hr />\n<h2 id=\"you-cannot-trust-what-you-cannot-see\">You Cannot Trust What You Cannot See</h2>\n<p>Silent fallbacks don't just hide configuration errors - they destroy your ability to reason about what the application is doing at any given moment. When a request behaves unexpectedly, your first question is always <em>what configuration was this running under?</em> If the honest answer is &quot;the real value or the hardcoded default, depending on whether the variable was set, which we don't log and which changes between environments,&quot; you're not debugging anymore. You're doing archaeology.</p>\n<blockquote>\n<p>Configuration should be explicit, visible, and verified at startup - not inferred at runtime from whatever happened to be set.</p>\n</blockquote>\n<hr />\n<h2 id=\"failing-loudly-is-an-act-of-kindness\">Failing Loudly Is an Act of Kindness</h2>\n<p>When your application crashes at startup with <code>FATAL: DATABASE_URL is not set</code>, you have given everyone involved an enormous gift. The developer sees it immediately, fixes it in thirty seconds, and moves on. The CI pipeline catches it before anything gets deployed. The on-call engineer, bleary-eyed at 2am, gets a clear signal rather than a mystery. Nobody has to infer that maybe the configuration was wrong - it says so, plainly, before any damage can be done.</p>\n<p>Make your application know what it needs, check for it at the very first moment of startup, and refuse to proceed if anything required is absent, because that is the entire argument and everything else in this post is elaboration on why the alternatives are worse. Don't let it limp forward on assumptions and defaults, silently degrading into a state where nobody can tell whether it's working correctly or just appearing to.</p>\n<p>Fail loudly, fail early, and fail before you've touched a single byte of real data.</p>\n",
      "date_published": "2026-06-01T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/bun-over-rust/",
      "url": "https://www.reepolee.com/engineering-notes/bun-over-rust/",
      "title": "Why We Use Bun and TypeScript Over Rust",
      "summary": "On cognitive overhead, one-language stacks, and Bun's build-free TypeScript execution - why Rust's strengths don't translate to typical web application work, and why LLM-generated code needs to be readable by the team that maintains it.",
      "content_html": "<p>Technology waves tend to follow the same pattern: a tool built to solve a very specific, very hard problem earns a deserved reputation, and then it starts showing up in contexts that don't share any of the characteristics that made it the right choice in the first place. Right now that tool is Rust, and it keeps appearing in internal dashboards and CRUD applications that serve a few hundred users.</p>\n<p>That's the wrong call. Not because Rust is bad - it's extraordinary, and I mean that without irony, because the problems it was designed to solve are genuinely hard and it solves them in ways that no other mainstream language does. But an extraordinary tool used in the wrong context produces ordinary outcomes, and sometimes quietly catastrophic ones, because the people who picked it spend their time fighting the tool rather than building the product. For ordinary web application work - the kind that most of us do most of the time - Bun and TypeScript are the better bet, and the reasons for that compound over the life of the project in ways that aren't obvious on the first day but become very obvious on the five-hundredth.</p>\n<hr />\n<h2 id=\"one-language-everywhere\">One Language, Everywhere</h2>\n<p>With Bun and TypeScript you write one language across the whole stack: server logic, database queries, the API layer, frontend components, build scripts, test runners, CI pipelines. You get strong typing, modern tooling, and the ability to share types and validation logic between the server and the client without a translation layer in between.</p>\n<p>Rust on the backend means your frontend engineers cannot read your server code. That gap is invisible on the org chart and very visible the moment something breaks at the boundary between them, when the person who could fix the frontend problem cannot follow the logic into the backend without context they don't have.</p>\n<hr />\n<h2 id=\"the-knowledge-base-is-enormous-and-that-matters\">The Knowledge Base Is Enormous - and That Matters</h2>\n<p>When someone on your team hits a confusing async boundary in TypeScript, an answer is minutes away: a thread, a documentation page, a reproducible example. The equivalent wall in Rust - lifetime errors, pin projections, async trait objects - sends them into language RFCs and forum threads that assume expertise they don't yet have. The language isn't harder for the sake of being harder; it's solving problems your CRUD app doesn't have, and your team pays the learning cost anyway, every time someone new joins or someone senior leaves.</p>\n<p>There is also something worth saying about maintenance over time. The developer who wrote the clever Rust service may not be there in two years. The developer who inherits it may not know the language. TypeScript code written three years ago by someone who has since left is still, overwhelmingly, readable by whoever picks it up today - and that readability, boring as it sounds, is a form of engineering value that doesn't show up in benchmarks.</p>\n<hr />\n<h2 id=\"bun-eliminates-the-build-step\">Bun Eliminates the Build Step</h2>\n<p>You run TypeScript directly. No compile step, no bundler config to debug, no fifteen-minute cold starts in CI while the build tool decides what it needs to do. You write a <code>.ts</code> file, run it with <code>bun run</code>, and it works. On a project that several developers will touch over several years, zero build tooling is not a small convenience - it is a maintenance advantage that keeps paying out long after launch, because the thing you didn't add is the thing you will never have to upgrade, migrate, or explain.</p>\n<hr />\n<h2 id=\"a-word-on-llms\">A Word on LLMs</h2>\n<p>LLMs are very good at generating TypeScript and JavaScript. The training data is vast, the patterns are well established, and a prompt for a Bun HTTP handler usually comes back as coherent, idiomatic, immediately runnable code - code you can read, adjust, and own without spending an afternoon understanding what the generator was thinking.</p>\n<p>The catch is that generated code still has to be read, understood, and owned by whoever ships it, and that means knowing the language it's written in well enough to catch what it got wrong. Excellent LLM-generated Rust is still a liability when nobody on the team can confidently follow its logic under pressure. TypeScript keeps the developer in the seat: the output is readable, fast to correct, and fast to reason about at two in the morning, which is exactly when you most need to be able to reason about it.</p>\n<hr />\n<h2 id=\"the-takeaway\">The Takeaway</h2>\n<p>Cognitive overhead kills more projects than slow runtimes do. For us, that means one language the whole team can read, on a stack with decades of accumulated knowledge and the largest developer community in the world behind it - one language from database query to browser component, no build step, shared types end to end, and LLM output the team can actually audit and own.</p>\n<p>In the long run, the boring choice wins. It wins because the team can move, because the next person can understand it, and because &quot;boring&quot; in technology usually means &quot;tested by so many people that the surprises are mostly gone.&quot; That is not a small thing.</p>\n",
      "date_published": "2026-05-28T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    },
    {
      "id": "https://www.reepolee.com/engineering-notes/language-difference-apps-pages/",
      "url": "https://www.reepolee.com/engineering-notes/language-difference-apps-pages/",
      "title": "Localized URLs for Pages, Not for Apps",
      "summary": "The two distinct URL strategies for multilingual software: fully localized slugs for content that needs to be indexed and shared, language-agnostic URLs for apps where language is a user property. Covers hreflang, the locale-prefix plus translated-slug hybrid, and the subdomain split for products that are both.",
      "content_html": "<p>We've shipped multilingual marketing sites, multilingual documentation, and multilingual back-office applications - sometimes for the same client, in the same year - and the same mistake keeps showing up in different projects: someone picks one URL strategy for &quot;internationalization&quot; and applies it everywhere, because the problem looks like one problem when it is actually two completely different ones that happen to share the word &quot;language.&quot;</p>\n<p>It doesn't work. The rules for language handling are entirely different depending on whether you're publishing content or running an application, and getting this wrong produces two distinct failure modes: broken SEO on the content side, and quietly absurd edge cases on the application side that nobody can cleanly answer.</p>\n<hr />\n<h2 id=\"the-webpage-case-language-belongs-in-the-url\">The Webpage Case: Language Belongs in the URL</h2>\n<p>When you're publishing content - a blog, a marketing site, a documentation portal - the URL <em>is</em> the identity of the page. Search engines index URLs. People share links. The URL is part of the document, not metadata about it.</p>\n<p>If your landing page lives at <code>example.com/</code> and you serve French to French visitors and English to English visitors based on the <code>Accept-Language</code> header alone, you've created an invisible problem: two people sharing &quot;the same link&quot; are seeing different content. Worse, a search engine sees one URL with one language, and that's the only version it indexes. Your French SEO is dead before you've written a single French sentence.</p>\n<p>A locale prefix like <code>/fr/</code> is a step in the right direction, but it only goes halfway. The slug itself needs to be translated too - a French speaker searching for &quot;premiers pas&quot; won't find <code>/fr/getting-started</code>, because the URL is still English to them.</p>\n<pre><code>❌ /en/getting-started        - locale prefix, English slug\n❌ /fr/getting-started        - locale prefix, still English slug\n✅ /getting-started           - English page, English slug\n✅ /premiers-pas              - French page, French slug\n✅ /erste-schritte            - German page, German slug\n</code></pre>\n<p>Each URL is a fully localized, independently addressable document, because the slug is part of the content and not metadata about it, and it should be translated like everything else on the page.</p>\n<h3 id=\"the-hybrid-approach-locale-prefix-and-translated-slug\">The Hybrid Approach: Locale Prefix and Translated Slug</h3>\n<p>There's a valid middle ground, especially useful at scale: combine the locale prefix with a translated slug.</p>\n<pre><code>✅ /en/getting-started        - English prefix, English slug\n✅ /fr/premiers-pas           - French prefix, French slug\n✅ /de/erste-schritte         - German prefix, German slug\n</code></pre>\n<p>This gives you the best of both. The locale prefix makes routing trivial - your server knows which language tree to serve without a slug lookup. The translated slug still captures local search keywords and reads natively to a French or German speaker. It also makes the site structure visually obvious from the address bar: everything under <code>/fr/</code> is French, full stop.</p>\n<p>The tradeoff is a slightly longer URL and a slug-translation map you have to maintain per language. For most content sites that's a fair price, and it pairs cleanly with <code>hreflang</code> annotations so search engines understand these are equivalent pages in different languages:</p>\n<pre><code class=\"language-html\">&lt;link rel=&quot;alternate&quot; hreflang=&quot;en&quot; href=&quot;https://example.com/en/getting-started&quot; /&gt;\n&lt;link rel=&quot;alternate&quot; hreflang=&quot;fr&quot; href=&quot;https://example.com/fr/premiers-pas&quot; /&gt;\n&lt;link rel=&quot;alternate&quot; hreflang=&quot;de&quot; href=&quot;https://example.com/de/erste-schritte&quot; /&gt;\n</code></pre>\n<p>Either approach - translated slug alone, or locale prefix with translated slug - is fine. What is never fine is a locale prefix with an untranslated slug. That's the worst of both: longer URLs that still aren't readable in the user's language, and no SEO upside to show for it.</p>\n<hr />\n<h2 id=\"the-app-case-language-does-not-belong-in-the-url\">The App Case: Language Does Not Belong in the URL</h2>\n<p>Now open any major project management tool, or your company's internal back-office system. The URL doesn't say <code>/en/</code>. It doesn't carry English slugs. That's correct, and it's intentional.</p>\n<p>In an application, you're not publishing documents - you're giving a person access to <em>their data</em>. The content isn't &quot;an English article&quot; or &quot;a French article.&quot; It's <em>your</em> tasks, <em>your</em> calendar, <em>your</em> records. Language is a property of the user, not the resource.</p>\n<p>Your coworker in Tokyo and your coworker in Berlin are looking at the same sprint board. It has one URL. One canonical identity. They each see it in their own language because their profile says so - not because they're at different addresses.</p>\n<pre><code>❌ app.example.com/en/projects/42       - locale in the URL\n❌ app.example.com/projets/42           - translated slug in an app\n✅ app.example.com/projects/42          - canonical, language-agnostic\n</code></pre>\n<p>If you put the language in the URL of an app, you immediately run into a stack of edge cases that nobody can answer cleanly. A user switches their language in settings - do you redirect them to a new URL? What happens to their open tabs? You're sending an email notification with a link to a ticket - which language prefix do you use, the sender's or the recipient's? Someone shares a link in a channel where half the team speaks Spanish and half speaks English - does the link change shape depending on who clicked it?</p>\n<blockquote>\n<p>There is no clean answer to any of these. You've modeled the problem incorrectly from the start.</p>\n</blockquote>\n<hr />\n<h2 id=\"the-mental-model\">The Mental Model</h2>\n<table>\n<thead>\n<tr><th></th><th>Webpage / Content Site</th><th>Application</th></tr>\n</thead>\n<tbody>\n<tr><td>What's being served</td><td>A document</td><td>A user's data</td></tr>\n<tr><td>Language is a property of</td><td>The content</td><td>The user</td></tr>\n<tr><td>Language stored in</td><td>The URL and slug</td><td>User profile or session</td></tr>\n<tr><td>Slug translated?</td><td>Yes</td><td>No</td></tr>\n<tr><td>Locale prefix?</td><td>Optional (slug must match)</td><td>Never</td></tr>\n<tr><td>Shareable link behavior</td><td>Both users see the same language</td><td>Each user sees their language</td></tr>\n<tr><td>SEO matters</td><td>Yes</td><td>No</td></tr>\n</tbody>\n</table>\n<hr />\n<h2 id=\"where-it-gets-blurry\">Where It Gets Blurry</h2>\n<p>Some products live in both worlds. A public marketing site with a &quot;Log in&quot; button that transitions into an authenticated application is the most common shape I deal with, and we usually reach for a clean subdomain split: the marketing and docs subdomain uses fully localized URLs with translated slugs, while the application subdomain does not. This isn't pedantry - it's being honest about what each URL represents. The marketing page is a document you want indexed and shared in a specific language. The project board is a workspace your user happens to view in a specific language. Those are different things, and the URL structure should reflect that distinction clearly enough that a new developer joining the project understands it without being told.</p>\n<p>If two people with different languages can share a URL and <em>should</em> see the same content, give it a fully localized URL with a translated slug. If they should each see it <em>their way</em>, keep language out of the URL entirely.</p>\n<p>Webpages are documents with identities that need to be indexed, shared, and found in the right language while apps are mirrors that reflect a user's data back at them in whatever language they prefer. The URL structure should reflect that distinction clearly enough that a new developer joining the project understands it without being told.</p>\n",
      "date_published": "2026-05-20T00:00:00.000Z",
      "authors": [
        {
          "name": "Aleš Vaupotič"
        }
      ]
    }
  ]
}
