<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Tetsuya Wakita</title>
    <description>The latest articles on Forem by Tetsuya Wakita (@wakita181009).</description>
    <link>https://forem.com/wakita181009</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3783039%2F6bb13ef1-37ee-4e8c-8edd-2323d014fc64.jpg</url>
      <title>Forem: Tetsuya Wakita</title>
      <link>https://forem.com/wakita181009</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/wakita181009"/>
    <language>en</language>
    <item>
      <title>I Rewrote python-dateutil in Rust — Even a Naive Port Is Up to 94x Faster</title>
      <dc:creator>Tetsuya Wakita</dc:creator>
      <pubDate>Wed, 08 Apr 2026 08:53:33 +0000</pubDate>
      <link>https://forem.com/wakita181009/i-rewrote-python-dateutil-in-rust-even-a-naive-port-is-up-to-94x-faster-4n4e</link>
      <guid>https://forem.com/wakita181009/i-rewrote-python-dateutil-in-rust-even-a-naive-port-is-up-to-94x-faster-4n4e</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — &lt;code&gt;pip install python-dateutil-rs&lt;/code&gt;, change one import, get 5x–94x faster date parsing, recurrence rules, and timezone lookups. It's a line-by-line Rust port via PyO3 — no code changes required. &lt;a href="https://github.com/wakita181009/dateutil-rs" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; | &lt;a href="https://pypi.org/project/python-dateutil-rs/" rel="noopener noreferrer"&gt;PyPI&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  python-dateutil Is Everywhere — and It's Pure Python
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dateutil.readthedocs.io/en/stable/" rel="noopener noreferrer"&gt;python-dateutil&lt;/a&gt; is one of the most depended-on packages in the Python ecosystem. With &lt;strong&gt;300M+ monthly downloads on PyPI&lt;/strong&gt;, it powers date parsing, relative deltas, recurrence rules, and timezone handling across countless applications.&lt;/p&gt;

&lt;p&gt;But it's written in pure Python. For hot paths — parsing thousands of ISO timestamps in a data pipeline, expanding recurring calendar events, computing relative dates in a loop — that means leaving significant performance on the table.&lt;/p&gt;

&lt;p&gt;What if you could make it up to 94x faster without changing a single line of your application code?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;python-dateutil-rs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's &lt;code&gt;python-dateutil-rs&lt;/code&gt; — a Rust-backed drop-in replacement I built using PyO3 and maturin. Same API, same behavior, dramatically faster.&lt;/p&gt;

&lt;p&gt;And here's the thing: &lt;strong&gt;this is a naive, line-by-line port&lt;/strong&gt;. The Rust code mirrors the original Python logic almost 1:1, with no Rust-specific optimizations yet. The speedups come purely from Rust's compiled nature — no dynamic dispatch, minimal GC pressure, no interpreter overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Approach: Module-by-Module Rewrite via PyO3
&lt;/h2&gt;

&lt;p&gt;A full rewrite of a battle-tested library is risky. Instead, I took an &lt;strong&gt;incremental approach&lt;/strong&gt;: rewrite each module in Rust independently, validate against the original test suite (~13,000 lines of tests, all passing), and ship value at every step.&lt;/p&gt;

&lt;p&gt;The architecture uses &lt;a href="https://github.com/PyO3/maturin" rel="noopener noreferrer"&gt;maturin&lt;/a&gt;'s mixed layout — a Rust extension module and Python wrapper live in the same package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dateutil_rs (Python package)
  ├── _native (Rust extension via PyO3/maturin)  ← all modules
  └── Python wrappers                             ← API compatibility layer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every module is now implemented in Rust: parser, relativedelta, rrule, tz, easter, utils, and weekday constants. The Python layer handles API compatibility — for example, serializing custom &lt;code&gt;parserinfo&lt;/code&gt; lookup tables and forwarding them to the Rust parser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dateutil_rs._native&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;isoparse&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dateutil_rs._native&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;parse&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;_parse_rs&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timestr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parserinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;parserinfo&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Serialize custom lookup tables → forward to Rust
&lt;/span&gt;        &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parserinfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_to_rust_config&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_parse_rs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timestr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parserinfo_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_parse_rs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timestr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API is identical to &lt;code&gt;python-dateutil&lt;/code&gt; — just change the import:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Before (python-dateutil)
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dateutil.parser&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;parse&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dateutil.relativedelta&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;relativedelta&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dateutil.rrule&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;rrule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MONTHLY&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dateutil.tz&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;gettz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tzutc&lt;/span&gt;

&lt;span class="c1"&gt;# After (python-dateutil-rs) — same code
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dateutil_rs.parser&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;parse&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dateutil_rs.relativedelta&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;relativedelta&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dateutil_rs.rrule&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;rrule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MONTHLY&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dateutil_rs.tz&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;gettz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tzutc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Benchmark Results
&lt;/h2&gt;

&lt;p&gt;All benchmarks: Python 3.13, macOS Apple Silicon M3 (arm64), Rust 1.86 stable, release build (&lt;code&gt;--release&lt;/code&gt;), measured with pytest-benchmark. Compared against python-dateutil 2.9.0.&lt;/p&gt;

&lt;h3&gt;
  
  
  At a Glance
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Module&lt;/th&gt;
&lt;th&gt;Speedup Range&lt;/th&gt;
&lt;th&gt;Highlight&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Timezone&lt;/td&gt;
&lt;td&gt;1.7x – &lt;strong&gt;94.3x&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Batch &lt;code&gt;gettz()&lt;/code&gt; with cache: 94.3x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ISO Parser&lt;/td&gt;
&lt;td&gt;5.1x – &lt;strong&gt;23.5x&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;With microseconds: 23.5x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RRule&lt;/td&gt;
&lt;td&gt;3.1x – &lt;strong&gt;20.0x&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;rrulestr&lt;/code&gt; with dtstart: 20.0x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RelativeDelta&lt;/td&gt;
&lt;td&gt;4.9x – &lt;strong&gt;18.7x&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Multiply by scalar: 18.7x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;General Parser&lt;/td&gt;
&lt;td&gt;1.3x – &lt;strong&gt;3.5x&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Fuzzy parsing: 3.5x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Easter&lt;/td&gt;
&lt;td&gt;3.2x – &lt;strong&gt;6.2x&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;1000 years batch: 6.2x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note on the 94.3x headline number&lt;/strong&gt;: The timezone benchmark includes a Rust-side &lt;code&gt;RwLock&amp;lt;HashMap&amp;gt;&lt;/code&gt; cache (similar to python-dateutil's &lt;code&gt;_TzFactory&lt;/code&gt;). Without caching, pure timezone operations still see 1.7x–3.8x speedups. The most representative single-operation speedup is &lt;strong&gt;23.5x&lt;/strong&gt; on ISO parsing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  ISO Parsing — Up to 23.5x Faster
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Benchmark&lt;/th&gt;
&lt;th&gt;python-dateutil&lt;/th&gt;
&lt;th&gt;dateutil-rs&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Date only&lt;/td&gt;
&lt;td&gt;0.81 µs&lt;/td&gt;
&lt;td&gt;0.08 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10.7x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Datetime&lt;/td&gt;
&lt;td&gt;2.06 µs&lt;/td&gt;
&lt;td&gt;0.10 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20.9x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Datetime + TZ&lt;/td&gt;
&lt;td&gt;3.11 µs&lt;/td&gt;
&lt;td&gt;0.61 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5.1x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With microseconds&lt;/td&gt;
&lt;td&gt;2.67 µs&lt;/td&gt;
&lt;td&gt;0.11 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;23.5x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7 various formats&lt;/td&gt;
&lt;td&gt;23.10 µs&lt;/td&gt;
&lt;td&gt;3.10 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.4x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  RelativeDelta — Up to 18.7x Faster
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Benchmark&lt;/th&gt;
&lt;th&gt;python-dateutil&lt;/th&gt;
&lt;th&gt;dateutil-rs&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Create simple&lt;/td&gt;
&lt;td&gt;0.94 µs&lt;/td&gt;
&lt;td&gt;0.19 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.9x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add months&lt;/td&gt;
&lt;td&gt;1.62 µs&lt;/td&gt;
&lt;td&gt;0.13 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12.7x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subtract&lt;/td&gt;
&lt;td&gt;3.03 µs&lt;/td&gt;
&lt;td&gt;0.18 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16.5x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiply by scalar&lt;/td&gt;
&lt;td&gt;1.49 µs&lt;/td&gt;
&lt;td&gt;0.08 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;18.7x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sequential add ×12&lt;/td&gt;
&lt;td&gt;19.41 µs&lt;/td&gt;
&lt;td&gt;1.70 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;11.4x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Month-end overflow&lt;/td&gt;
&lt;td&gt;1.74 µs&lt;/td&gt;
&lt;td&gt;0.13 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13.3x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  RRule (Recurrence Rules) — Up to 20x Faster
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Benchmark&lt;/th&gt;
&lt;th&gt;python-dateutil&lt;/th&gt;
&lt;th&gt;dateutil-rs&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Weekly ×52&lt;/td&gt;
&lt;td&gt;105.33 µs&lt;/td&gt;
&lt;td&gt;34.44 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.1x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly ×120&lt;/td&gt;
&lt;td&gt;448.46 µs&lt;/td&gt;
&lt;td&gt;114.12 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.9x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yearly ×100&lt;/td&gt;
&lt;td&gt;2,144.58 µs&lt;/td&gt;
&lt;td&gt;235.02 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9.1x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rrulestr simple&lt;/td&gt;
&lt;td&gt;3.14 µs&lt;/td&gt;
&lt;td&gt;0.53 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6.0x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rrulestr complex&lt;/td&gt;
&lt;td&gt;6.60 µs&lt;/td&gt;
&lt;td&gt;0.90 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.4x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rrulestr with dtstart&lt;/td&gt;
&lt;td&gt;15.61 µs&lt;/td&gt;
&lt;td&gt;0.78 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20.0x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Timezone — Up to 94.3x Faster (Cache-Inclusive)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Benchmark&lt;/th&gt;
&lt;th&gt;python-dateutil&lt;/th&gt;
&lt;th&gt;dateutil-rs&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;gettz various (×10)&lt;/td&gt;
&lt;td&gt;714.93 µs&lt;/td&gt;
&lt;td&gt;7.59 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;94.3x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gettz offset&lt;/td&gt;
&lt;td&gt;20.20 µs&lt;/td&gt;
&lt;td&gt;5.26 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.8x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;resolve_imaginary&lt;/td&gt;
&lt;td&gt;6.54 µs&lt;/td&gt;
&lt;td&gt;3.19 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.0x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;datetime_exists&lt;/td&gt;
&lt;td&gt;3.15 µs&lt;/td&gt;
&lt;td&gt;1.62 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.9x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;convert chain&lt;/td&gt;
&lt;td&gt;6.74 µs&lt;/td&gt;
&lt;td&gt;4.08 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.7x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 94.3x speedup on batch &lt;code&gt;gettz()&lt;/code&gt; comes from a process-global &lt;code&gt;RwLock&amp;lt;HashMap&amp;gt;&lt;/code&gt; cache — similar to python-dateutil's &lt;code&gt;_TzFactory&lt;/code&gt;, but with Rust's near-zero-cost &lt;code&gt;Arc&lt;/code&gt; clone for cached timezone objects. Even without the cache hit, individual timezone operations see 1.7x–3.8x improvements from compiled execution alone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Easter — Up to 6.2x Faster
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Benchmark&lt;/th&gt;
&lt;th&gt;python-dateutil&lt;/th&gt;
&lt;th&gt;dateutil-rs&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Single call&lt;/td&gt;
&lt;td&gt;0.51 µs&lt;/td&gt;
&lt;td&gt;0.13 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.9x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1000 years&lt;/td&gt;
&lt;td&gt;437.88 µs&lt;/td&gt;
&lt;td&gt;70.46 µs&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6.2x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why Is It Fast? It's Just Rust Being Rust
&lt;/h2&gt;

&lt;p&gt;Here's the surprising part: &lt;strong&gt;the Rust code is a faithful, almost line-by-line translation of the Python source&lt;/strong&gt;. I didn't design clever algorithms or exploit Rust-specific data structures. The speedups come from the fundamental differences between compiled and interpreted execution.&lt;/p&gt;

&lt;p&gt;Profiling revealed that Python's overhead isn't in any single place — it's &lt;strong&gt;everywhere&lt;/strong&gt;. Every function call, every attribute access, every integer comparison, every object allocation pays an interpreter tax. In a tight loop, these micro-costs compound dramatically. That's why even a naive port sees 5x–94x improvements on batch operations.&lt;/p&gt;

&lt;p&gt;Let's look at three modules to see exactly where the performance comes from:&lt;/p&gt;

&lt;h3&gt;
  
  
  isoparse: Byte-Level Parsing (23.5x)
&lt;/h3&gt;

&lt;p&gt;The original &lt;code&gt;isoparser&lt;/code&gt; in Python uses string slicing and &lt;code&gt;int()&lt;/code&gt; conversion. The Rust version does the same logic — but on byte arrays instead of Python strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;parse_isodate_common&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dt_str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;u8&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nb"&gt;i32&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1i32&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// [year, month, day]&lt;/span&gt;

    &lt;span class="c1"&gt;// Year: parse 4 ASCII bytes directly&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;dt_str&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;has_sep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dt_str&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sc"&gt;b'-'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;has_sep&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Month: 2 bytes&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;dt_str&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Day: 2 bytes&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same algorithm as the Python version. No string allocation, no regex, no dynamic dispatch — just the inherent overhead of interpretation removed. The &lt;code&gt;parse_int&lt;/code&gt; helper converts ASCII bytes to integers with a multiply-and-add loop. For a microsecond field like &lt;code&gt;123456&lt;/code&gt;, this is dramatically faster than Python's &lt;code&gt;int("123456")&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Result: &lt;code&gt;"2024-01-15T10:30:00.123456+09:00"&lt;/code&gt; goes from 2.67 µs to 0.11 µs.&lt;/p&gt;

&lt;h3&gt;
  
  
  RelativeDelta: Fixed Struct vs Dynamic Attributes (18.7x)
&lt;/h3&gt;

&lt;p&gt;Python's &lt;code&gt;relativedelta&lt;/code&gt; stores fields as instance attributes with &lt;code&gt;getattr&lt;/code&gt;/&lt;code&gt;setattr&lt;/code&gt; patterns. The Rust version uses a fixed struct — same fields, same logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;RelativeDelta&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Relative (plural) fields — offsets&lt;/span&gt;
    &lt;span class="n"&gt;years&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;i32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;months&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;i32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;microseconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// Absolute (singular) fields — "set to this value"&lt;/span&gt;
    &lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;i32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;i32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;i32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multiplication is just multiplying each field — no dictionary lookups, no attribute resolution, no type coercion overhead. Same algorithm, 18.7x faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  RRule: Same Algorithm, Less Overhead (9.1x–20x)
&lt;/h3&gt;

&lt;p&gt;Recurrence rules (RFC 5545) are the most complex module — they expand patterns like "every 3rd Tuesday of the month" into concrete dates. The Rust implementation follows the same expansion algorithm as python-dateutil:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stack-allocated date arithmetic via &lt;code&gt;chrono&lt;/code&gt; instead of Python &lt;code&gt;datetime&lt;/code&gt; objects&lt;/li&gt;
&lt;li&gt;No per-iteration object allocation or GC pressure&lt;/li&gt;
&lt;li&gt;Integer comparisons instead of Python object protocol (&lt;code&gt;__lt__&lt;/code&gt;, &lt;code&gt;__eq__&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For yearly rules over 100 occurrences, the overhead compounds: 2,144 µs → 235 µs. For &lt;code&gt;rrulestr&lt;/code&gt; parsing, the gap is even wider — 20x faster because string-to-rule conversion involves heavy tokenization that benefits enormously from compiled execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next: v1 — Actually Optimized Rust
&lt;/h2&gt;

&lt;p&gt;Everything above is &lt;strong&gt;v0&lt;/strong&gt; — a compatibility-first port that proves Rust's floor is Python's ceiling. The Rust code deliberately mirrors Python's logic to ensure behavioral fidelity against 13,000 lines of inherited tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v1&lt;/strong&gt; is where things get interesting. It will be a clean Rust-native implementation with real optimizations:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Optimization&lt;/th&gt;
&lt;th&gt;Technique&lt;/th&gt;
&lt;th&gt;Target Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Zero-copy parsing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;&amp;amp;str&lt;/code&gt; slices instead of &lt;code&gt;String&lt;/code&gt; clones&lt;/td&gt;
&lt;td&gt;Parser: 5x–50x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Compile-time hash maps&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;phf&lt;/code&gt; perfect hash for weekday/month lookup&lt;/td&gt;
&lt;td&gt;Parser: faster tokenization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Buffer reuse&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pre-allocated buffers cleared across iterations&lt;/td&gt;
&lt;td&gt;RRule: 5x–15x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Minimal allocations&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SmallVec&lt;/code&gt;, stack buffers, arena patterns&lt;/td&gt;
&lt;td&gt;All modules&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Streamlined API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Drop rarely-used features (fuzzy parse, POSIX tz)&lt;/td&gt;
&lt;td&gt;Smaller, faster core&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;v1 will ship as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;dateutil&lt;/code&gt; on &lt;strong&gt;crates.io&lt;/strong&gt; — pure Rust crate, no PyO3 dependency&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dateutil&lt;/code&gt; on &lt;strong&gt;PyPI&lt;/strong&gt; — thin PyO3 binding layer wrapping the Rust core&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The v0 → v1 migration path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v0.x: faithful python-dateutil port (current — 1.3x–94x faster)
v1.x: Rust-optimized core (target — 5x–100x faster)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a naive port already delivers up to 94x speedups, imagine what happens when the Rust code is actually written like Rust.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned Building This
&lt;/h2&gt;

&lt;h3&gt;
  
  
  PyO3 Is Remarkably Ergonomic
&lt;/h3&gt;

&lt;p&gt;PyO3's derive macros make exposing Rust types to Python straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[pyclass(name&lt;/span&gt; &lt;span class="nd"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"relativedelta"&lt;/span&gt;&lt;span class="nd"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;RelativeDelta&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[pymethods]&lt;/span&gt;
&lt;span class="k"&gt;impl&lt;/span&gt; &lt;span class="n"&gt;RelativeDelta&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;#[new]&lt;/span&gt;
    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* 15+ optional kwargs */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;__add__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Bound&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;'_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PyAny&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PyResult&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PyObject&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Handle datetime + relativedelta&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trickiest part was &lt;code&gt;datetime&lt;/code&gt; ↔ &lt;code&gt;chrono&lt;/code&gt; conversion. PyO3's &lt;code&gt;chrono&lt;/code&gt; feature handles the basics, but timezone-aware datetimes require careful handling of the Python &lt;code&gt;tzinfo&lt;/code&gt; protocol.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Faithful Port Is a Valid Strategy
&lt;/h3&gt;

&lt;p&gt;The instinct when rewriting in Rust is to redesign everything. I resisted that. By keeping the logic 1:1 with Python, I could validate correctness against the original test suite — and still ship massive speedups. The optimization pass (v1) comes later, built on a foundation that is already proven correct.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the Overhead Actually Is
&lt;/h3&gt;

&lt;p&gt;The takeaway is clear: CPython's per-operation overhead is small in isolation, but it compounds multiplicatively in loops. A date parser that touches 10 fields per call, called 10,000 times, pays the interpreter tax 100,000 times. Rust pays it zero times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;python-dateutil-rs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/wakita181009/dateutil-rs" rel="noopener noreferrer"&gt;wakita181009/dateutil-rs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PyPI&lt;/strong&gt;: &lt;a href="https://pypi.org/project/python-dateutil-rs/" rel="noopener noreferrer"&gt;python-dateutil-rs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python&lt;/strong&gt;: 3.10–3.14 on Linux and macOS (Windows support planned)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project is open source (MIT). If your application use Ws &lt;code&gt;python-dateutil&lt;/code&gt;, switching is a one-line import change. v0 is stable, fully tested, and API-compatible. v1 — the Rust-optimized core with zero-copy parsing and &lt;code&gt;phf&lt;/code&gt; hash maps — is coming.&lt;/p&gt;

&lt;p&gt;The numbers speak for themselves. And once zero-copy parsing and arena allocation land in v1, they're about to get a lot bigger.&lt;/p&gt;

</description>
      <category>python</category>
      <category>rust</category>
      <category>maturin</category>
      <category>dateutil</category>
    </item>
    <item>
      <title>Zod vs Typia vs AJV — I Built a Build Plugin That Makes Zod 60x Faster With Zero Code Changes</title>
      <dc:creator>Tetsuya Wakita</dc:creator>
      <pubDate>Sun, 29 Mar 2026 17:39:43 +0000</pubDate>
      <link>https://forem.com/wakita181009/zod-vs-typia-vs-ajv-i-built-a-vite-plugin-that-makes-zod-60x-faster-with-zero-code-changes-1poc</link>
      <guid>https://forem.com/wakita181009/zod-vs-typia-vs-ajv-i-built-a-vite-plugin-that-makes-zod-60x-faster-with-zero-code-changes-1poc</guid>
      <description>&lt;p&gt;When I first released Zod AOT, it required wrapping every schema with &lt;code&gt;compile()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;compile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod-aot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The #1 feedback was: &lt;strong&gt;"I don't want to change my code."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Fair. So I removed that requirement. Zod AOT now has an &lt;code&gt;autoDiscover&lt;/code&gt; mode — a Vite plugin that finds your Zod schemas at build time, compiles them into optimized validators, and replaces them in-place. Your schema files stay pure Zod. No imports from zod-aot. No wrappers. Just this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;zodAot&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod-aot/vite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;zodAot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;autoDiscover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Every exported Zod schema in your project gets AOT-compiled at build time.&lt;/p&gt;

&lt;p&gt;I also added Typia and AJV to the benchmarks, built a two-phase "Fast Path" validator, and shipped a diagnostic CLI. Here's what changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  autoDiscover: How It Works
&lt;/h2&gt;

&lt;p&gt;The challenge is detecting Zod schemas without any marker in the source code. There's no &lt;code&gt;compile()&lt;/code&gt; call, no special import — just plain Zod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/schemas.ts — plain Zod, no Zod AOT import&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CreateUserSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;editor&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;viewer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UpdateUserSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;editor&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;viewer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plugin detects and compiles these in four steps:&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Regex Pre-filter
&lt;/h3&gt;

&lt;p&gt;Before doing any work, the plugin checks if the file contains a runtime Zod import:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;   &lt;span class="err"&gt;✓&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt; &lt;span class="nx"&gt;definitions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;  &lt;span class="err"&gt;✗&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;only&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="nx"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Files without a runtime Zod import are skipped entirely. This keeps build times fast — most files in your project aren't schema files.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Execute and Inspect Exports
&lt;/h3&gt;

&lt;p&gt;The plugin loads the source file at build time using &lt;a href="https://github.com/unjs/jiti" rel="noopener noreferrer"&gt;jiti&lt;/a&gt; and inspects each export:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isZodSchema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;_zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;zod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;_zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;zod&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;zod&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;def&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;zod&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every Zod schema has a &lt;code&gt;_zod.def&lt;/code&gt; structure — this is the internal definition that describes the schema's type, checks, and children. If an export has this marker, it's a Zod schema.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Compile to Optimized Validators
&lt;/h3&gt;

&lt;p&gt;Each discovered schema goes through the Zod AOT compilation pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Zod Schema → Extract IR → Generate Code → Wrap in IIFE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline walks the schema tree, extracts an intermediate representation, and generates flat, inlined validation code — the same process as before, but triggered automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: AST-Based Source Replacement
&lt;/h3&gt;

&lt;p&gt;The plugin uses &lt;a href="https://github.com/acornjs/acorn" rel="noopener noreferrer"&gt;acorn&lt;/a&gt; to parse expression boundaries in the source code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before (source)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CreateUserSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// After (build output)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CreateUserSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="cm"&gt;/* @__PURE__ */&lt;/span&gt; &lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ... generated validation code ...&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;__w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;originalSchema&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;__w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* optimized */&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nx"&gt;__w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;safeParse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;__validate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;__w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;originalSchema&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;__w&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;})();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Object.create(originalSchema)&lt;/code&gt; is key — it preserves the full Zod API. Your compiled schema still has &lt;code&gt;.shape&lt;/code&gt;, &lt;code&gt;.keyof()&lt;/code&gt;, &lt;code&gt;.pick()&lt;/code&gt;, &lt;code&gt;.merge()&lt;/code&gt;, and everything else. Framework code that inspects schema metadata (like tRPC or React Hook Form resolvers) continues to work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Build Output
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;verbose: true&lt;/code&gt;, you see what happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[zod-aot] Auto-discovering: src/schemas.ts (4 Zod exports found)
[zod-aot]   ✓ CreateUserSchema
[zod-aot]   ✓ UpdateUserSchema
[zod-aot]   ✓ ListUsersSchema
[zod-aot]   ✓ UserIdSchema
[zod-aot] Build summary: 4/4 schemas optimized across 1 file(s)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Scoping
&lt;/h3&gt;

&lt;p&gt;Not every file should be auto-discovered — you probably don't want to execute test fixtures or files with side effects at build time. Use &lt;code&gt;include&lt;/code&gt; and &lt;code&gt;exclude&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;zodAot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;autoDiscover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;src/schemas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;exclude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mock&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two-Phase Validation: The Fast Path
&lt;/h2&gt;

&lt;p&gt;The prior version of Zod AOT generated error-collecting validators — they always created an issues array and tracked every validation failure. That's necessary for error reporting, but it's overhead when the input is valid.&lt;/p&gt;

&lt;p&gt;In production, &lt;strong&gt;most inputs are valid&lt;/strong&gt;. So I added a Fast Path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 1: Boolean Expression Chain
&lt;/h3&gt;

&lt;p&gt;For valid inputs, the entire schema is validated with a single boolean expression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Generated Fast Path for CreateUserSchema&lt;/span&gt;
&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="o"&gt;===&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="o"&gt;!==&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;!&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="o"&gt;===&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="o"&gt;===&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;__re0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;age&lt;/span&gt;&lt;span class="o"&gt;===&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;age&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isSafeInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;age&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;age&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;age&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;=&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="o"&gt;===&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="o"&gt;===&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;editor&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="o"&gt;===&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;viewer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this evaluates to &lt;code&gt;true&lt;/code&gt;, the function returns &lt;code&gt;{ success: true, data: input }&lt;/code&gt; immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero allocations.&lt;/strong&gt; No issues array. No error objects. No path arrays. Just a boolean chain that short-circuits on the first failure.&lt;/p&gt;

&lt;p&gt;Regex patterns and enum Sets are pre-compiled in a preamble:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;__re0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-zA-Z0-9._%+-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+@&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-zA-Z0-9.-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.[&lt;/span&gt;&lt;span class="sr"&gt;a-zA-Z&lt;/span&gt;&lt;span class="se"&gt;]{2,}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// Small enums (≤3 values) use inline === checks instead of Set.has()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Phase 2: Error-Collecting Path (Slow Path)
&lt;/h3&gt;

&lt;p&gt;If the Fast Path fails — meaning the input has at least one issue — the full error-collecting validator runs. This creates the standard &lt;code&gt;{ success: false, error: { issues: [...] } }&lt;/code&gt; response with codes, paths, and messages.&lt;/p&gt;

&lt;p&gt;The key insight: &lt;strong&gt;the Slow Path only runs when it's actually needed.&lt;/strong&gt; For hot API endpoints where 95%+ of requests pass validation, the Fast Path is all that executes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fast Path Eligibility
&lt;/h3&gt;

&lt;p&gt;Not every schema qualifies for a Fast Path. Schemas with &lt;code&gt;.transform()&lt;/code&gt;, &lt;code&gt;.refine()&lt;/code&gt;, &lt;code&gt;.default()&lt;/code&gt;, &lt;code&gt;.coerce()&lt;/code&gt;, or &lt;code&gt;.catch()&lt;/code&gt; are ineligible because they need to modify the input or run opaque functions. The &lt;code&gt;check&lt;/code&gt; command (see below) tells you which schemas qualify.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 5-Way Benchmark
&lt;/h2&gt;

&lt;p&gt;I added &lt;a href="https://github.com/samchon/typia" rel="noopener noreferrer"&gt;Typia&lt;/a&gt; and &lt;a href="https://github.com/ajv-validator/ajv" rel="noopener noreferrer"&gt;AJV&lt;/a&gt; to the benchmark suite. All five validators parse the same data with equivalent schemas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zod v3&lt;/strong&gt;: Interpreter model, walks schema tree on every call&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zod v4&lt;/strong&gt;: New architecture with JIT object compilation via &lt;code&gt;new Function()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zod AOT&lt;/strong&gt;: Build-time compilation with two-phase validation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typia&lt;/strong&gt;: Compile-time code generation via TypeScript transformer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AJV&lt;/strong&gt;: JSON Schema validator with ahead-of-time compilation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Environment: Node.js v22, Apple M-series, Vitest bench, 1M+ iterations with warmup.&lt;/p&gt;

&lt;h3&gt;
  
  
  Full Results
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Schema&lt;/th&gt;
&lt;th&gt;Zod v3&lt;/th&gt;
&lt;th&gt;Zod v4&lt;/th&gt;
&lt;th&gt;Zod AOT&lt;/th&gt;
&lt;th&gt;Typia&lt;/th&gt;
&lt;th&gt;AJV&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;String min/max&lt;/td&gt;
&lt;td&gt;8.3M&lt;/td&gt;
&lt;td&gt;5.6M&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10.6M&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10.5M&lt;/td&gt;
&lt;td&gt;8.8M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enum&lt;/td&gt;
&lt;td&gt;7.6M&lt;/td&gt;
&lt;td&gt;9.3M&lt;/td&gt;
&lt;td&gt;10.2M&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10.8M&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10.7M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium object (valid)&lt;/td&gt;
&lt;td&gt;1.3M&lt;/td&gt;
&lt;td&gt;1.7M&lt;/td&gt;
&lt;td&gt;5.2M&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.0M&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4.8M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium object (invalid)&lt;/td&gt;
&lt;td&gt;365K&lt;/td&gt;
&lt;td&gt;63K&lt;/td&gt;
&lt;td&gt;471K&lt;/td&gt;
&lt;td&gt;2.1M&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.7M&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large object — 100 items&lt;/td&gt;
&lt;td&gt;8.6K&lt;/td&gt;
&lt;td&gt;11.6K&lt;/td&gt;
&lt;td&gt;627K&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;808K&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;89K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discriminated union (3)&lt;/td&gt;
&lt;td&gt;2.3M&lt;/td&gt;
&lt;td&gt;3.0M&lt;/td&gt;
&lt;td&gt;10.2M&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10.6M&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5.4M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recursive tree — 121 nodes&lt;/td&gt;
&lt;td&gt;23K&lt;/td&gt;
&lt;td&gt;103K&lt;/td&gt;
&lt;td&gt;733K&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.4M&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;251K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Set (20 items)&lt;/td&gt;
&lt;td&gt;980K&lt;/td&gt;
&lt;td&gt;461K&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.6M&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Map (20 entries)&lt;/td&gt;
&lt;td&gt;479K&lt;/td&gt;
&lt;td&gt;235K&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5.1M&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Event log (mixed types)&lt;/td&gt;
&lt;td&gt;263K&lt;/td&gt;
&lt;td&gt;473K&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.5M&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partial fallback — 50 items&lt;/td&gt;
&lt;td&gt;18K&lt;/td&gt;
&lt;td&gt;30K&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;238K&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;ops/sec — higher is better. "—" = type not supported by the library.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the numbers tell us:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Primitives&lt;/strong&gt; — All AOT approaches converge around ~10.5M ops/sec. This is the V8 ceiling for simple type checks. No meaningful difference between Zod AOT, Typia, and AJV.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Objects&lt;/strong&gt; — Typia leads (1.3x over Zod AOT on medium, 1.2x on large). But the gap narrows as objects grow — the Fast Path's single boolean chain scales well. AJV collapses on large objects (9x slower than Typia at 100 items). Zod v4 is 3-54x slower than both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invalid inputs&lt;/strong&gt; — AJV dominates the error path (4.7M ops/s) with minimal error construction. Zod v4 is slowest at 63K due to rich structured errors. In practice, most production inputs are valid — the Fast Path handles those.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Collections (Set/Map)&lt;/strong&gt; — Zod AOT's exclusive territory. Typia doesn't validate Set or Map contents. AJV doesn't support JS-native types. Zod AOT is &lt;strong&gt;16-22x faster&lt;/strong&gt; than Zod v4 here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recursive&lt;/strong&gt; — Typia leads 1.9x over Zod AOT on deep trees. Zod AOT currently uses &lt;code&gt;z.lazy()&lt;/code&gt; fallback for recursive references — improving this is on the roadmap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partial fallback&lt;/strong&gt; — Schemas with &lt;code&gt;.transform()&lt;/code&gt; can't be fully AOT-compiled, but Zod AOT compiles everything it can and delegates the rest to Zod. Even partial compilation delivers &lt;strong&gt;2.6-8x&lt;/strong&gt; over Zod v4.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Scoreboard
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Winner&lt;/th&gt;
&lt;th&gt;Runner-up&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Primitives&lt;/td&gt;
&lt;td&gt;Tie (all AOT ≈ 10.5M)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Objects (valid)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Typia&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Zod AOT (within 1.2x)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Objects (invalid)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;AJV&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Typia&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collections (Set/Map)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Zod AOT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;(only competitor)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discriminated unions&lt;/td&gt;
&lt;td&gt;Tie (Typia ≈ Zod AOT)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recursive&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Typia&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Zod AOT (1.9x gap)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partial compilation&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Zod AOT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;(only competitor)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zod AOT sits in a unique position: &lt;strong&gt;near-Typia performance with full Zod API compatibility.&lt;/strong&gt; You don't need type-level tags, a different schema DSL, or JSON Schema. You keep &lt;code&gt;z.object()&lt;/code&gt;, &lt;code&gt;z.email()&lt;/code&gt;, &lt;code&gt;.transform()&lt;/code&gt;, &lt;code&gt;.refine()&lt;/code&gt; — and the build plugin handles the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;check&lt;/code&gt; Command: Know Your Coverage
&lt;/h2&gt;

&lt;p&gt;Not sure what percentage of your schemas will be compiled? Run &lt;code&gt;check&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx zod-aot check src/schemas.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;validateUser — 100% compiled (8/8 nodes) | Fast Path: eligible
  └─ ✓ object
     ├─ ✓ string .name
     ├─ ✓ string .email
     ├─ ✓ number .age
     ├─ ✓ enum .role
     ├─ ✓ boolean .isActive
     ├─ ✓ array .tags
     │  └─ ✓ string .tags[]
     └─ ✓ nullable .bio
        └─ ✓ string .bio

validateOrder — 80% compiled (4/5 nodes) | Fast Path: ineligible
  └─ ✓ object
     ├─ ✓ string .orderId
     ├─ ✓ number .amount
     ├─ ✓ enum .currency
     └─ ✗ fallback .slug (transform)
           hint: Extract transform into a separate post-processing step
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each schema gets a tree view showing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which nodes are fully compiled (✓) vs falling back to Zod (✗)&lt;/li&gt;
&lt;li&gt;Compilation coverage percentage&lt;/li&gt;
&lt;li&gt;Fast Path eligibility&lt;/li&gt;
&lt;li&gt;Actionable hints for improving coverage&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  CI Integration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Fail if compilation coverage drops below 80%&lt;/span&gt;
npx zod-aot check src/schemas/ &lt;span class="nt"&gt;--fail-under&lt;/span&gt; 80 &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--json&lt;/code&gt; flag outputs structured data for CI pipelines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exportName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"validateOrder"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"coverage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"compilable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"percent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fastPath"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"eligible"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"blocker"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fallback (transform)"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fallbacks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"transform"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".slug"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"hint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Extract transform into a separate post-processing step"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Framework Integration
&lt;/h2&gt;

&lt;p&gt;autoDiscover works at the build layer — framework code doesn't know or care:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;tRPC&lt;/strong&gt;: Schemas auto-compiled, router unchanged.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// router.ts — no changes needed&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;appRouter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;router&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;createUser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;publicProcedure&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CreateUserSchema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// ← this is now AOT-compiled&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Hono&lt;/strong&gt;: Middleware validation auto-optimized.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;zValidator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;CreateUserSchema&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// CreateUserSchema is AOT-compiled at build time&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;React Hook Form&lt;/strong&gt;: Resolver schemas auto-compiled.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;register&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useForm&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;resolver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;zodResolver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CreateUserSchema&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// AOT-compiled&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Object.create()&lt;/code&gt; wrapper ensures that framework code reading &lt;code&gt;.shape&lt;/code&gt;, &lt;code&gt;.keyof()&lt;/code&gt;, or other Zod metadata still works — the compiled schema inherits from the original.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned from the Competition
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Typia&lt;/strong&gt; is the performance king for objects and recursive structures. Its compile-time TypeScript transformer generates monolithic validation functions with zero runtime overhead. But:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It requires type-level tags (&lt;code&gt;tags.MinLength&amp;lt;3&amp;gt;&lt;/code&gt;) instead of method chaining — a different mental model&lt;/li&gt;
&lt;li&gt;It doesn't validate Set or Map contents&lt;/li&gt;
&lt;li&gt;No bigint support&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;.transform()&lt;/code&gt; or &lt;code&gt;.refine()&lt;/code&gt; — it's a validator, not a parser&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;AJV&lt;/strong&gt; has the best error-path performance and the broadest ecosystem (JSON Schema is a standard). But:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JSON Schema doesn't support JavaScript-native types (Set, Map)&lt;/li&gt;
&lt;li&gt;Verbose schema definitions — no chaining, no composition&lt;/li&gt;
&lt;li&gt;No transforms or pipes — declarative validation only&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Zod AOT&lt;/strong&gt; fills the gap: Zod's composable API, near-Typia performance, full JavaScript type support, and partial compilation for schemas that mix validation with transformation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get Started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;zod-aot zod@^4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.ts — that's the entire setup&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;zodAot&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod-aot/vite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;zodAot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;autoDiscover&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your existing Zod schemas are now AOT-compiled. No code changes.&lt;/p&gt;

&lt;p&gt;Also works with webpack, esbuild, Rollup, Rolldown, rspack, and Bun via &lt;a href="https://github.com/unjs/unplugin" rel="noopener noreferrer"&gt;unplugin&lt;/a&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/wakita181009/zod-aot" rel="noopener noreferrer"&gt;github.com/wakita181009/zod-aot&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;npm&lt;/strong&gt;: &lt;a href="https://www.npmjs.com/package/zod-aot" rel="noopener noreferrer"&gt;zod-aot&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;p&gt;All benchmarks use &lt;code&gt;safeParse()&lt;/code&gt; on pre-created schema instances, measured with Vitest bench (1M+ iterations, with warmup). Numbers are steady-state throughput — JIT is already compiled after the first call. Typia uses &lt;code&gt;createValidate&amp;lt;T&amp;gt;()&lt;/code&gt;. AJV uses &lt;code&gt;ajv.compile(schema)&lt;/code&gt;. Invalid-input benchmarks use all-fields-invalid payloads to stress the error path.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Environment&lt;/strong&gt;: Node.js v22, Apple M-series&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Libraries&lt;/strong&gt;: Zod v3.24, Zod v4, Zod AOT 0.14, Typia 12, AJV 8&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw data&lt;/strong&gt;: &lt;a href="https://github.com/wakita181009/zod-aot/tree/main/benchmarks" rel="noopener noreferrer"&gt;benchmarks/&lt;/a&gt; in the zod-aot repo&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>performance</category>
      <category>typescript</category>
      <category>zod</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why Is Zod v4 Fast — and Where Is Its Ceiling?</title>
      <dc:creator>Tetsuya Wakita</dc:creator>
      <pubDate>Thu, 19 Mar 2026 20:48:52 +0000</pubDate>
      <link>https://forem.com/wakita181009/why-is-zod-v4-fast-and-where-is-its-ceiling-280l</link>
      <guid>https://forem.com/wakita181009/why-is-zod-v4-fast-and-where-is-its-ceiling-280l</guid>
      <description>&lt;p&gt;Zod v4 is faster than v3. But almost nobody knows &lt;em&gt;why&lt;/em&gt;. It's not just a cleaner codebase or smaller bundle — Zod v4 has a JIT-like code generator that builds specialized validation functions at runtime using &lt;code&gt;new Function()&lt;/code&gt;. It's hidden inside a class called &lt;code&gt;$ZodObjectJIT&lt;/code&gt;, and unless you read the source, you'd never know it was there.&lt;/p&gt;

&lt;p&gt;I read the internals to understand how v4's performance works, benchmarked every schema type, and used what I learned to build an AOT compiler that extends the same idea to build time. Here's everything I found.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the benchmarks show:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;v4's JIT targets &lt;strong&gt;object schemas&lt;/strong&gt; — delivering 1.3-4.5x over v3 for objects and recursive structures&lt;/li&gt;
&lt;li&gt;For other types (primitives with checks, collections), v4 traded raw throughput for a richer architecture (better errors, async support, modular checks)&lt;/li&gt;
&lt;li&gt;AOT compilation extends the JIT principle to &lt;strong&gt;all schema types&lt;/strong&gt;: 3-60x faster than v4&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Zod v3: The Interpreter Model
&lt;/h2&gt;

&lt;p&gt;Every time you call &lt;code&gt;.parse()&lt;/code&gt; in Zod v3, the same thing happens: the entire schema tree gets walked at runtime. Each node dispatches based on type — string, number, object, array — and the traversal recurses until it hits the leaves.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified: what Zod v3 does on every .parse() call&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;typeName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ZodString&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parseString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_def&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ZodNumber&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parseNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_def&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ZodObject&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parseObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_def&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// recurses into each property&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ZodArray&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parseArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_def&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// recurses into each element&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is an interpreter. The schema is a data structure, and Zod walks it every time. For a simple &lt;code&gt;z.string()&lt;/code&gt;, the overhead is negligible. For a nested object with 100 items, you're traversing hundreds of nodes on every single call.&lt;/p&gt;

&lt;p&gt;The schema never changes. But Zod re-discovers its structure every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zod v4: A New Architecture
&lt;/h2&gt;

&lt;p&gt;Zod v4 introduced a fundamentally different internal architecture. Every schema now has a &lt;code&gt;_zod&lt;/code&gt; object with two key functions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;_zod.parse&lt;/code&gt;&lt;/strong&gt;: The type-specific validation logic (type check, structural validation)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;_zod.run&lt;/code&gt;&lt;/strong&gt;: Orchestrates &lt;code&gt;parse&lt;/code&gt; + runs checks (&lt;code&gt;.min()&lt;/code&gt;, &lt;code&gt;.max()&lt;/code&gt;, &lt;code&gt;.email()&lt;/code&gt;, etc.)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nx"&gt;_zod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;issues&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nx"&gt;_zod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="nf"&gt;runChecks&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a schema has no checks, Zod v4 optimizes by making &lt;code&gt;_zod.run&lt;/code&gt; point directly to &lt;code&gt;_zod.parse&lt;/code&gt; — skipping the check orchestration entirely.&lt;/p&gt;

&lt;p&gt;The new architecture also brought real improvements beyond performance: structured error objects with full path tracking, first-class async support, modular check pipelines, and &lt;code&gt;globalConfig&lt;/code&gt; for runtime tuning. These features have a small cost in the hot path — a deliberate trade-off that makes Zod v4 far more capable than v3.&lt;/p&gt;

&lt;p&gt;But the most interesting optimization is hidden deeper.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hidden JIT: &lt;code&gt;$ZodObjectJIT&lt;/code&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Doc Class: Runtime Code Generation
&lt;/h3&gt;

&lt;p&gt;Deep inside Zod v4's source (&lt;code&gt;schemas.js&lt;/code&gt;), there's a class called &lt;code&gt;Doc&lt;/code&gt;. It doesn't validate data. It generates JavaScript code as strings and compiles them into functions using &lt;code&gt;new Function()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// From zod/v4/core/doc.js — a runtime code generator&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Doc&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Accumulates lines of JavaScript source code&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;F&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// new Function("arg1", "arg2", "...code...") → executable function&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;F&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is runtime code generation: it builds JavaScript source as strings and compiles it with &lt;code&gt;new Function()&lt;/code&gt; for reuse on later parses. Strictly speaking, this isn't a full optimizing JIT like V8 or HotSpot — but it plays a similar role: specialize once, reuse many times.&lt;/p&gt;

&lt;h3&gt;
  
  
  How $ZodObjectJIT Works
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;$ZodObjectJIT&lt;/code&gt; wraps the standard &lt;code&gt;$ZodObject&lt;/code&gt; parser. On the first &lt;code&gt;.parse()&lt;/code&gt; call, it generates a specialized validation function for that specific object shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// From zod/v4/core/schemas.js — simplified&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;$ZodObjectJIT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;core&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;$constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$ZodObjectJIT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;$ZodObject&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;superParse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_zod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// Save the original&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;generateFastpass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Doc&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shape&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;payload&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ctx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`const input = payload.value;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`const newResult = {};`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Generate inline validation code for each property&lt;/span&gt;
      &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`const r_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; = shape["&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"]._zod.run(
        { value: input["&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"], issues: [] }, ctx);`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`if (r_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.issues.length) { /* merge issues */ }`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`newResult["&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"] = r_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.value;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`payload.value = newResult;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`return payload;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// → new Function(...)&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;fastpass&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// Cached compiled function&lt;/span&gt;

  &lt;span class="nx"&gt;inst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_zod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jit&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;fastEnabled&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jitless&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;fastpass&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;fastpass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generateFastpass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;def&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// First call only&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fastpass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;superParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// Fallback&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the first parse call:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Iterates over the object shape&lt;/li&gt;
&lt;li&gt;Generates JavaScript code that validates each property inline&lt;/li&gt;
&lt;li&gt;Compiles it with &lt;code&gt;new Function()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Caches the compiled function in &lt;code&gt;fastpass&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every subsequent call skips the code generation and executes the cached function directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Environment Detection
&lt;/h3&gt;

&lt;p&gt;Before the JIT path kicks in, Zod v4 checks whether the runtime allows &lt;code&gt;new Function()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;core&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globalConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jitless&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fastEnabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jit&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;allowsEval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// From zod/v4/core/util.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allowsEval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cloudflare&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// Cloudflare Workers blocks eval&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;F&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;F&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// CSP-restricted environments&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;new Function()&lt;/code&gt; is blocked — Cloudflare Workers, strict CSP headers, or &lt;code&gt;jitless&lt;/code&gt; mode — Zod v4 gracefully falls back to the standard parse path. The validation result is identical; only the execution speed differs.&lt;/p&gt;

&lt;h3&gt;
  
  
  What JIT Covers (and What It Doesn't)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;$ZodObjectJIT&lt;/code&gt; targets the most impactful case: &lt;strong&gt;object schemas&lt;/strong&gt;. Objects are by far the most common complex schema in real applications — every API request body, every form, every database row.&lt;/p&gt;

&lt;p&gt;But it's specifically scoped to objects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;z.string().min(1).email()&lt;/code&gt; — uses the standard check pipeline&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;z.number().int().positive()&lt;/code&gt; — uses the standard check pipeline&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;z.array(z.string())&lt;/code&gt; — standard parse (though nested objects inside get JIT)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;z.set()&lt;/code&gt;, &lt;code&gt;z.map()&lt;/code&gt;, &lt;code&gt;z.record()&lt;/code&gt; — standard parse&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;z.discriminatedUnion()&lt;/code&gt; — has its own separate optimization (a &lt;code&gt;Map&lt;/code&gt; lookup table)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The generated code also still calls &lt;code&gt;shape[key]._zod.run()&lt;/code&gt; for each property — it delegates to child schemas at runtime. The JIT eliminates the object-level dispatch overhead, but the per-property traversal remains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmarks: Where JIT Helps
&lt;/h2&gt;

&lt;p&gt;I benchmarked Zod v3, Zod v4, and Zod AOT across every schema type. The results show exactly where the JIT makes a difference.&lt;/p&gt;

&lt;h3&gt;
  
  
  Objects and Recursive Structures: JIT Territory
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Schema&lt;/th&gt;
&lt;th&gt;Zod v3&lt;/th&gt;
&lt;th&gt;Zod v4&lt;/th&gt;
&lt;th&gt;Zod AOT&lt;/th&gt;
&lt;th&gt;v3 → v4&lt;/th&gt;
&lt;th&gt;v4 → AOT&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Medium object (valid)&lt;/td&gt;
&lt;td&gt;1.3M ops/s&lt;/td&gt;
&lt;td&gt;1.6M ops/s&lt;/td&gt;
&lt;td&gt;5.5M ops/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.31x faster&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3.31x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large object (10 items)&lt;/td&gt;
&lt;td&gt;81K&lt;/td&gt;
&lt;td&gt;113K&lt;/td&gt;
&lt;td&gt;4.0M&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.39x faster&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;35.8x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large object (100 items)&lt;/td&gt;
&lt;td&gt;8.9K&lt;/td&gt;
&lt;td&gt;12.0K&lt;/td&gt;
&lt;td&gt;725K&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.35x faster&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;60.6x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recursive tree (7 nodes)&lt;/td&gt;
&lt;td&gt;420K&lt;/td&gt;
&lt;td&gt;1.5M&lt;/td&gt;
&lt;td&gt;6.4M&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.69x faster&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4.15x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recursive tree (121 nodes)&lt;/td&gt;
&lt;td&gt;23K&lt;/td&gt;
&lt;td&gt;105K&lt;/td&gt;
&lt;td&gt;739K&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.49x faster&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;7.05x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discriminated union (3)&lt;/td&gt;
&lt;td&gt;2.4M&lt;/td&gt;
&lt;td&gt;2.9M&lt;/td&gt;
&lt;td&gt;10.1M&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.18x faster&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3.54x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is where v4 shines. For valid object inputs, the JIT fast path delivers 1.3-1.4x over v3. For recursive structures — where each level hits an object schema — the JIT compounds: 3.7-4.5x over v3.&lt;/p&gt;

&lt;h3&gt;
  
  
  Primitives: Architecture Trade-off Visible
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Schema&lt;/th&gt;
&lt;th&gt;Zod v3&lt;/th&gt;
&lt;th&gt;Zod v4&lt;/th&gt;
&lt;th&gt;Zod AOT&lt;/th&gt;
&lt;th&gt;v3 → v4&lt;/th&gt;
&lt;th&gt;v4 → AOT&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Simple string&lt;/td&gt;
&lt;td&gt;8.5M&lt;/td&gt;
&lt;td&gt;9.6M&lt;/td&gt;
&lt;td&gt;11.0M&lt;/td&gt;
&lt;td&gt;1.13x faster&lt;/td&gt;
&lt;td&gt;1.15x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;String min/max&lt;/td&gt;
&lt;td&gt;8.0M&lt;/td&gt;
&lt;td&gt;5.8M&lt;/td&gt;
&lt;td&gt;10.3M&lt;/td&gt;
&lt;td&gt;0.73x&lt;/td&gt;
&lt;td&gt;1.78x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Number int+pos&lt;/td&gt;
&lt;td&gt;7.9M&lt;/td&gt;
&lt;td&gt;5.7M&lt;/td&gt;
&lt;td&gt;11.0M&lt;/td&gt;
&lt;td&gt;0.73x&lt;/td&gt;
&lt;td&gt;1.93x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enum&lt;/td&gt;
&lt;td&gt;7.3M&lt;/td&gt;
&lt;td&gt;9.2M&lt;/td&gt;
&lt;td&gt;10.4M&lt;/td&gt;
&lt;td&gt;1.26x faster&lt;/td&gt;
&lt;td&gt;1.13x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bigint min/max&lt;/td&gt;
&lt;td&gt;7.5M&lt;/td&gt;
&lt;td&gt;5.4M&lt;/td&gt;
&lt;td&gt;10.2M&lt;/td&gt;
&lt;td&gt;0.72x&lt;/td&gt;
&lt;td&gt;1.89x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For primitives with checks, v4's modular check pipeline (separate check objects, &lt;code&gt;runChecks&lt;/code&gt; orchestration) adds overhead compared to v3's simpler inline approach. This is the cost of v4's new features — structured errors, conditional checks via &lt;code&gt;when&lt;/code&gt;, and the ability to compose check pipelines. The JIT doesn't apply here since these aren't object schemas.&lt;/p&gt;

&lt;p&gt;At ~6M ops/s, this is still extremely fast for any real application. The difference only shows up in micro-benchmarks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Collections: No JIT (Yet)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Schema&lt;/th&gt;
&lt;th&gt;Zod v3&lt;/th&gt;
&lt;th&gt;Zod v4&lt;/th&gt;
&lt;th&gt;Zod AOT&lt;/th&gt;
&lt;th&gt;v3 → v4&lt;/th&gt;
&lt;th&gt;v4 → AOT&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tuple&lt;/td&gt;
&lt;td&gt;3.9M&lt;/td&gt;
&lt;td&gt;4.4M&lt;/td&gt;
&lt;td&gt;10.4M&lt;/td&gt;
&lt;td&gt;1.12x faster&lt;/td&gt;
&lt;td&gt;2.38x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Record (5 keys)&lt;/td&gt;
&lt;td&gt;2.3M&lt;/td&gt;
&lt;td&gt;1.6M&lt;/td&gt;
&lt;td&gt;5.4M&lt;/td&gt;
&lt;td&gt;0.69x&lt;/td&gt;
&lt;td&gt;3.51x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Set (5 items)&lt;/td&gt;
&lt;td&gt;2.7M&lt;/td&gt;
&lt;td&gt;1.5M&lt;/td&gt;
&lt;td&gt;9.8M&lt;/td&gt;
&lt;td&gt;0.58x&lt;/td&gt;
&lt;td&gt;6.35x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Set (20 items)&lt;/td&gt;
&lt;td&gt;964K&lt;/td&gt;
&lt;td&gt;464K&lt;/td&gt;
&lt;td&gt;7.7M&lt;/td&gt;
&lt;td&gt;0.48x&lt;/td&gt;
&lt;td&gt;16.5x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Map (5 entries)&lt;/td&gt;
&lt;td&gt;1.5M&lt;/td&gt;
&lt;td&gt;954K&lt;/td&gt;
&lt;td&gt;8.5M&lt;/td&gt;
&lt;td&gt;0.62x&lt;/td&gt;
&lt;td&gt;8.96x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Map (20 entries)&lt;/td&gt;
&lt;td&gt;489K&lt;/td&gt;
&lt;td&gt;239K&lt;/td&gt;
&lt;td&gt;5.2M&lt;/td&gt;
&lt;td&gt;0.49x&lt;/td&gt;
&lt;td&gt;21.6x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Collections (Set, Map, Record) go through the new internal pipeline without JIT assistance. The throughput difference vs v3 reflects the richer v4 architecture, not a regression — v4 does more work per element (payload construction, issue path tracking, async readiness).&lt;/p&gt;

&lt;p&gt;This is where AOT makes the biggest relative difference: 16-22x for 20-item collections, since AOT can eliminate the per-element overhead entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Invalid Inputs: Error Construction Dominates
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Schema&lt;/th&gt;
&lt;th&gt;Zod v3&lt;/th&gt;
&lt;th&gt;Zod v4&lt;/th&gt;
&lt;th&gt;Zod AOT&lt;/th&gt;
&lt;th&gt;v3 → v4&lt;/th&gt;
&lt;th&gt;v4 → AOT&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Medium object (all-fields-invalid)&lt;/td&gt;
&lt;td&gt;381K&lt;/td&gt;
&lt;td&gt;62K&lt;/td&gt;
&lt;td&gt;476K&lt;/td&gt;
&lt;td&gt;0.16x&lt;/td&gt;
&lt;td&gt;7.64x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The most dramatic difference is on invalid inputs. This benchmark uses an all-fields-invalid payload to stress-test the error path. v4's error construction is significantly richer than v3's — detailed issue objects with codes, paths, metadata, and i18n support. That richness has a cost in throughput benchmarks.&lt;/p&gt;

&lt;p&gt;In real workloads where most inputs are valid, this gap is much smaller. And when inputs &lt;em&gt;are&lt;/em&gt; invalid, v4's structured errors make debugging far easier — that's a worthwhile trade-off.&lt;/p&gt;

&lt;h3&gt;
  
  
  Real-World Schemas
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Schema&lt;/th&gt;
&lt;th&gt;Zod v3&lt;/th&gt;
&lt;th&gt;Zod v4&lt;/th&gt;
&lt;th&gt;Zod AOT&lt;/th&gt;
&lt;th&gt;v3 → v4&lt;/th&gt;
&lt;th&gt;v4 → AOT&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Event log (mixed types)&lt;/td&gt;
&lt;td&gt;265K&lt;/td&gt;
&lt;td&gt;472K&lt;/td&gt;
&lt;td&gt;4.5M&lt;/td&gt;
&lt;td&gt;1.78x faster&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9.55x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partial fallback (object)&lt;/td&gt;
&lt;td&gt;797K&lt;/td&gt;
&lt;td&gt;1.4M&lt;/td&gt;
&lt;td&gt;3.4M&lt;/td&gt;
&lt;td&gt;1.77x faster&lt;/td&gt;
&lt;td&gt;2.43x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partial fallback (50 arr)&lt;/td&gt;
&lt;td&gt;18K&lt;/td&gt;
&lt;td&gt;29K&lt;/td&gt;
&lt;td&gt;252K&lt;/td&gt;
&lt;td&gt;1.61x faster&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.69x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Real-world schemas mix objects, discriminated unions, tuples, and records. Here v4's JIT contributes meaningfully: 1.6-1.8x over v3. AOT extends this further: 2.4-9.6x over v4.&lt;/p&gt;

&lt;p&gt;The "partial fallback" rows show schemas with &lt;code&gt;.transform()&lt;/code&gt; — fields that can't be AOT-compiled. zod-aot falls back to Zod for those fields while compiling the rest. Even partial compilation delivers 2.4-8.7x over v4.&lt;/p&gt;

&lt;h2&gt;
  
  
  From JIT to AOT: Taking It Further
&lt;/h2&gt;

&lt;p&gt;v4's JIT gave me the idea for zod-aot. The insight is the same: &lt;strong&gt;schemas are static, so validation can be specialized ahead of time&lt;/strong&gt;. The JIT does this at runtime for objects. AOT does it at build time for everything.&lt;/p&gt;

&lt;p&gt;Here's the same schema, side by side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;positive&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Zod v4 JIT output&lt;/strong&gt; (generated at runtime via &lt;code&gt;new Function&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;

&lt;span class="c1"&gt;// Each property still delegates to the child schema's _zod.run()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;_zod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r_name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;r_name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iss&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;iss&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;iss&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[])]&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;newResult&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r_name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r_email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;_zod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ... same pattern for each property&lt;/span&gt;

&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newResult&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JIT eliminates the object-level dispatch loop. But each property still calls &lt;code&gt;_zod.run()&lt;/code&gt; → &lt;code&gt;_zod.parse()&lt;/code&gt; → &lt;code&gt;runChecks()&lt;/code&gt;. The per-property overhead remains.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zod AOT output&lt;/strong&gt; (generated at build time):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;safeParse_User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;__issues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;__issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invalid_type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Fully inlined — no function calls, no delegation&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;__issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invalid_type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;__issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;too_small&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;minimum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;__issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;too_big&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;maximum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;__issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invalid_type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;__re_email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;__issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invalid_format&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;age&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;age&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;__issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invalid_type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;age&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isSafeInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;age&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;__issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invalid_type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;int&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;age&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;age&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;__issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;too_small&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;minimum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;age&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;__issues&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No schema objects. No &lt;code&gt;_zod.run()&lt;/code&gt; calls. No check pipeline. Flat, inlined validation with zero dispatch overhead.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Zod v4 JIT&lt;/th&gt;
&lt;th&gt;Zod AOT&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;When compiled&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;First &lt;code&gt;.parse()&lt;/code&gt; call&lt;/td&gt;
&lt;td&gt;Build time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scope&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Object-level dispatch only&lt;/td&gt;
&lt;td&gt;Full schema tree, all types&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Per-property cost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;_zod.run()&lt;/code&gt; + checks pipeline&lt;/td&gt;
&lt;td&gt;Inlined type checks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Environment&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Requires &lt;code&gt;new Function()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Works everywhere (no eval)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cold start&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;First parse pays compilation cost&lt;/td&gt;
&lt;td&gt;Zero — function exists at startup&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Where AOT Matters Most
&lt;/h3&gt;

&lt;p&gt;In a long-running Node.js server, v4's JIT warms up on the first request and stays fast. AOT's advantage is smaller here.&lt;/p&gt;

&lt;p&gt;But in environments where JIT can't help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Workers / strict CSP&lt;/strong&gt;: &lt;code&gt;new Function()&lt;/code&gt; is blocked. Zod v4 falls back to the standard parse path. AOT works everywhere — no eval needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serverless with frequent cold starts&lt;/strong&gt;: Every new Lambda/Edge instance pays the JIT compilation cost. AOT has zero cold-start penalty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collection-heavy validation&lt;/strong&gt;: Set, Map, and Record schemas don't benefit from JIT. AOT delivers 16-22x for these.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High-throughput APIs&lt;/strong&gt;: When you're validating complex nested objects at &amp;gt;10K requests/sec, AOT's 35-60x improvement over v4 is the difference between needing 1 server and needing 60.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Trade-offs
&lt;/h3&gt;

&lt;p&gt;AOT isn't free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.transform()&lt;/code&gt;, &lt;code&gt;.refine()&lt;/code&gt;, &lt;code&gt;.superRefine()&lt;/code&gt;&lt;/strong&gt; contain opaque functions that can't be compiled ahead of time. zod-aot falls back to Zod for these fields (partial compilation still helps, but the benefit is proportional to how much of your schema is compilable).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build integration required&lt;/strong&gt; — a bundler plugin (Vite, webpack, esbuild) or CLI step. It's 4 lines of config, but not zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generated code adds to bundle size&lt;/strong&gt; — for most schemas this is smaller than Zod itself, but it's a factor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Static schemas only&lt;/strong&gt; — if you construct schemas dynamically at runtime, AOT can't help.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When to Use What
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Recommendation&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prototyping / small schemas&lt;/td&gt;
&lt;td&gt;Zod v4 as-is&lt;/td&gt;
&lt;td&gt;JIT is invisible, zero setup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long-running API server&lt;/td&gt;
&lt;td&gt;Zod v4 is likely sufficient&lt;/td&gt;
&lt;td&gt;JIT warms up on first request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare Workers / strict CSP&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Zod AOT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JIT is disabled — AOT works everywhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serverless with frequent cold starts&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Zod AOT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Zero cold-start compilation cost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complex nested objects at high throughput&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Zod AOT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;35-60x over v4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Set/Map/Record heavy validation&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Zod AOT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JIT doesn't cover these, AOT does&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schemas dominated by &lt;code&gt;.transform()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Zod v4&lt;/td&gt;
&lt;td&gt;AOT can't compile opaque functions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Try Zod AOT
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;zod-aot zod@^4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;compile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod-aot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Wrap with compile() — same API, AOT-compiled in production&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;positive&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// .parse(), .safeParse(), .is() — all work as expected&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the Vite plugin (4 lines):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;zodAot&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod-aot/vite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;zodAot&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In development, &lt;code&gt;compile()&lt;/code&gt; passes through to Zod. In production builds, the plugin replaces it with generated validation code. Zero code changes to your schemas.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/wakita181009/zod-aot" rel="noopener noreferrer"&gt;github.com/wakita181009/zod-aot&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;npm&lt;/strong&gt;: &lt;a href="https://www.npmjs.com/package/zod-aot" rel="noopener noreferrer"&gt;zod-aot&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Methodology
&lt;/h2&gt;

&lt;p&gt;All benchmarks use &lt;code&gt;safeParse()&lt;/code&gt; on pre-created schema instances, measured with Vitest bench (1M+ iterations per case, with warmup). Zod v4 object benchmarks report steady-state throughput — the JIT fast path is already compiled after the first call. Invalid-input benchmarks use all-fields-invalid payloads to stress the error path. Async paths are excluded.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Environment&lt;/strong&gt;: Node.js v22, Apple M-series&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Libraries&lt;/strong&gt;: Zod v3.24, Zod v4.3, Zod AOT latest&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw data&lt;/strong&gt;: &lt;a href="https://github.com/wakita181009/zod-aot/tree/main/benchmarks" rel="noopener noreferrer"&gt;benchmarks/&lt;/a&gt; in the zod-aot repo&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>typescript</category>
      <category>zod</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Build Google Apps Script with Any Bundler: Vite, Rollup, esbuild, webpack, and Bun</title>
      <dc:creator>Tetsuya Wakita</dc:creator>
      <pubDate>Sat, 14 Mar 2026 16:37:45 +0000</pubDate>
      <link>https://forem.com/wakita181009/build-google-apps-script-with-any-bundler-vite-rollup-esbuild-webpack-and-bun-2e73</link>
      <guid>https://forem.com/wakita181009/build-google-apps-script-with-any-bundler-vite-rollup-esbuild-webpack-and-bun-2e73</guid>
      <description>&lt;p&gt;&lt;em&gt;Google Apps Script projects shouldn’t be tied to one bundler.&lt;br&gt;&lt;br&gt;
So I rebuilt my Vite-only plugin into a universal plugin for Vite, Rollup, webpack, esbuild, and Bun — and added a scaffolding CLI on top.&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  The problem with modern bundlers in Google Apps Script
&lt;/h2&gt;

&lt;p&gt;If you’ve ever deployed a GAS project only to find that &lt;code&gt;doGet&lt;/code&gt; was silently removed by tree-shaking — or that &lt;code&gt;export&lt;/code&gt; keywords broke the Apps Script runtime — you know the pain.&lt;/p&gt;

&lt;p&gt;Google Apps Script has a slightly awkward relationship with modern JavaScript tooling.&lt;/p&gt;

&lt;p&gt;Bundlers like Vite, Rollup, webpack, and esbuild are great at optimizing modules. But GAS expects certain functions — &lt;code&gt;doGet&lt;/code&gt;, &lt;code&gt;doPost&lt;/code&gt;, &lt;code&gt;onOpen&lt;/code&gt;, installable triggers, menu handlers — to exist in the global scope with stable names.&lt;/p&gt;

&lt;p&gt;That creates a few recurring problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;export&lt;/code&gt; is convenient in source code, but GAS doesn’t want it in the final bundle&lt;/li&gt;
&lt;li&gt;tree-shaking can remove entry points that are only referenced by the Apps Script runtime&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;appsscript.json&lt;/code&gt; has to be copied into the output alongside the bundle&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here’s what the plugin actually does to your output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Your source:&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;doGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;HtmlService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHtmlOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hello&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Without plugin — bundler may wrap, rename, or tree-shake:&lt;/span&gt;
&lt;span class="c1"&gt;// doGet is gone or unreachable&lt;/span&gt;

&lt;span class="c1"&gt;// With @gas-plugin/unplugin — export stripped, global preserved:&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;doGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;HtmlService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHtmlOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hello&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my previous post, I introduced &lt;strong&gt;gas-vite-plugin&lt;/strong&gt;, a small Vite plugin that handled exactly that.&lt;/p&gt;

&lt;p&gt;It worked well — but only for Vite.&lt;/p&gt;

&lt;p&gt;If your team used Rollup, webpack, or esbuild, you had to solve the same problem a different way.&lt;/p&gt;

&lt;p&gt;So I rebuilt it as a universal plugin.&lt;/p&gt;




&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;The project is now &lt;strong&gt;@gas-plugin&lt;/strong&gt; — a monorepo with two packages:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.npmjs.com/package/@gas-plugin/unplugin" rel="noopener noreferrer"&gt;&lt;code&gt;@gas-plugin/unplugin&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Universal bundler plugin for Vite, Rollup, webpack, esbuild, and Bun&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.npmjs.com/package/@gas-plugin/cli" rel="noopener noreferrer"&gt;&lt;code&gt;@gas-plugin/cli&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Scaffolding CLI for new GAS projects&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The original &lt;strong&gt;gas-vite-plugin&lt;/strong&gt; still exists as a deprecated compatibility package, but the main path going forward is &lt;code&gt;@gas-plugin/unplugin&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I rebuilt it with unplugin
&lt;/h2&gt;

&lt;p&gt;I could have maintained separate plugins for each bundler.&lt;/p&gt;

&lt;p&gt;That would have meant duplicating the same logic across multiple packages: transform exported entry points, preserve GAS globals, and copy the manifest into the output directory.&lt;/p&gt;

&lt;p&gt;Instead, I used &lt;strong&gt;unplugin&lt;/strong&gt;, which lets you implement the plugin once and expose adapters for multiple bundlers. For this kind of narrowly focused build-time behavior, that’s a much better fit than maintaining five independent implementations.&lt;/p&gt;

&lt;p&gt;The result is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;same plugin behavior&lt;/li&gt;
&lt;li&gt;same options&lt;/li&gt;
&lt;li&gt;different import path per bundler&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That means the GAS-specific parts stay consistent even if your build tool changes.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;code&gt;@gas-plugin/unplugin&lt;/code&gt;: one plugin, any bundler
&lt;/h2&gt;

&lt;p&gt;Here’s the same plugin working across different bundlers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Vite
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;gasPlugin&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@gas-plugin/unplugin/vite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;gasPlugin&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;lib&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;src/main.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;formats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;fileName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Code.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Rollup
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;gasPlugin&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@gas-plugin/unplugin/rollup&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;src/main.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dist&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;gasPlugin&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  esbuild
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;gasPlugin&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@gas-plugin/unplugin/esbuild&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;build&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;esbuild&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;entryPoints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;src/main.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;outdir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dist&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;bundle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;esm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;gasPlugin&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  webpack
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;gasPlugin&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@gas-plugin/unplugin/webpack&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./src/main.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./dist&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;gasPlugin&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Bun
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;gasPlugin&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@gas-plugin/unplugin/bun&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Bun&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;entrypoints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;src/main.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;outdir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dist&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;gasPlugin&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The options are the same everywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;gasPlugin&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;appsscript.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;src/**/*.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;globals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;processData&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;autoGlobals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  How &lt;code&gt;autoGlobals&lt;/code&gt; and &lt;code&gt;globals&lt;/code&gt; work
&lt;/h3&gt;

&lt;p&gt;By default, bundlers may tree-shake or rename your GAS entry points like &lt;code&gt;doGet&lt;/code&gt; or &lt;code&gt;onOpen&lt;/code&gt; — because nothing in your code imports them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;autoGlobals: true&lt;/code&gt;&lt;/strong&gt; scans your source for exported functions and automatically exposes them as global declarations in the output. This is the simplest way to make sure your GAS entry points survive the build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;globals: ["processData"]&lt;/code&gt;&lt;/strong&gt; lets you manually specify additional functions that should be preserved as globals — useful for installable triggers or menu callbacks that aren’t exported from the entry point.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can use both together: &lt;code&gt;autoGlobals&lt;/code&gt; handles the common case, and &lt;code&gt;globals&lt;/code&gt; covers edge cases.&lt;/p&gt;

&lt;p&gt;That consistency is the main goal: you shouldn’t have to rethink your GAS build setup just because you prefer one bundler over another.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;code&gt;@gas-plugin/cli&lt;/code&gt;: scaffold a project in seconds
&lt;/h2&gt;

&lt;p&gt;I also added a CLI so you can start a new project without wiring everything manually.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @gas-plugin/cli create
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You’ll get interactive prompts for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;project name&lt;/li&gt;
&lt;li&gt;template&lt;/li&gt;
&lt;li&gt;bundler&lt;/li&gt;
&lt;li&gt;optional clasp configuration&lt;/li&gt;
&lt;li&gt;optional Script ID&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;◆  Project name
│  my-gas-app
│
◆  Template
│  ● basic — Spreadsheet automation (onOpen, triggers)
│  ○ webapp — Web app (doGet/doPost + HTML client)
│
◆  Bundler
│  ● vite
│  ○ rollup
│  ○ esbuild
│  ○ webpack
│  ○ bun
│
◆  Include clasp configuration?
│  Yes
│
◆  Script ID (optional)
│  1BxTjDm...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if you prefer automation-friendly setup, you can skip prompts entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @gas-plugin/cli create my-gas-app &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--template&lt;/span&gt; webapp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--bundler&lt;/span&gt; vite &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--clasp&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--script-id&lt;/span&gt; YOUR_SCRIPT_ID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated project is intentionally minimal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my-gas-app/
├── src/
│   ├── index.ts
│   ├── utils.ts
│   └── client.html       # webapp template only
├── package.json
├── appsscript.json
├── tsconfig.json
├── biome.json
├── vite.config.ts        # or rollup/esbuild/webpack config
├── .clasp.json           # when --clasp
├── .claspignore
└── .gitignore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run build
npx clasp push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you the basics you actually need, without forcing a larger opinionated toolchain on every project.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this exists when &lt;code&gt;google/aside&lt;/code&gt; already exists
&lt;/h2&gt;

&lt;p&gt;Google already provides &lt;a href="https://github.com/google/aside" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;code&gt;@google/aside&lt;/code&gt;&lt;/strong&gt;&lt;/a&gt;, and it’s a legitimate option.&lt;/p&gt;

&lt;p&gt;So this project is &lt;strong&gt;not&lt;/strong&gt; trying to replace it outright.&lt;/p&gt;

&lt;p&gt;The difference is scope.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;google/aside&lt;/code&gt; is a batteries-included starter
&lt;/h3&gt;

&lt;p&gt;It gives you a more opinionated setup out of the box.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;@gas-plugin&lt;/code&gt; is a smaller, composable alternative
&lt;/h3&gt;

&lt;p&gt;It focuses on two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;making bundled output work cleanly for GAS&lt;/li&gt;
&lt;li&gt;letting you choose your own bundler and surrounding toolchain&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here’s the practical difference:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;google/aside&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;@gas-plugin&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bundler choice&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Primarily Rollup-based setup&lt;/td&gt;
&lt;td&gt;Vite, Rollup, esbuild, webpack, Bun&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;export&lt;/code&gt; handling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;You work within the template’s conventions&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;export&lt;/code&gt; is stripped at build time for GAS output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tree-shaking protection&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Side-effect import pattern&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;autoGlobals&lt;/code&gt; can preserve exported entry points automatically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scaffolding style&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;More batteries included&lt;/td&gt;
&lt;td&gt;Minimal scaffold, bring your own tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Templates&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Opinionated starter&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;basic&lt;/code&gt; and &lt;code&gt;webapp&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the real distinction is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;choose &lt;strong&gt;&lt;code&gt;google/aside&lt;/code&gt;&lt;/strong&gt; if you want a fuller default setup&lt;/li&gt;
&lt;li&gt;choose &lt;strong&gt;&lt;code&gt;@gas-plugin&lt;/code&gt;&lt;/strong&gt; if you want a lighter, bundler-agnostic foundation&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Migration from &lt;code&gt;gas-vite-plugin&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;If you’re already using the old package, migration is intentionally small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- import gasPlugin from "gas-vite-plugin";
&lt;/span&gt;&lt;span class="gi"&gt;+ import gasPlugin from "@gas-plugin/unplugin/vite";
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm uninstall gas-vite-plugin
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; @gas-plugin/unplugin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same idea, same behavior — just no longer tied to Vite alone.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I think this matters
&lt;/h2&gt;

&lt;p&gt;GAS is still incredibly useful for internal tools, automations, lightweight web apps, and spreadsheet-driven workflows.&lt;/p&gt;

&lt;p&gt;But the tooling ecosystem has often pushed developers into one of two extremes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;stay close to vanilla Apps Script and give up modern build ergonomics&lt;/li&gt;
&lt;li&gt;adopt a very specific template and toolchain&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wanted something in between:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;modern TypeScript workflow&lt;/li&gt;
&lt;li&gt;support for the bundler you already use&lt;/li&gt;
&lt;li&gt;minimal GAS-specific friction&lt;/li&gt;
&lt;li&gt;no unnecessary lock-in&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s what &lt;code&gt;@gas-plugin&lt;/code&gt; is trying to be.&lt;/p&gt;

&lt;p&gt;If you’ve tried it, I’d love to hear which bundler you’re using and whether anything felt off. Issues and PRs are welcome on &lt;a href="https://github.com/wakita181009/gas-plugin" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/wakita181009/gas-plugin" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/strong&gt; — source, examples, issues&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href="https://www.npmjs.com/package/@gas-plugin/unplugin" rel="noopener noreferrer"&gt;@gas-plugin/unplugin&lt;/a&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href="https://www.npmjs.com/package/@gas-plugin/cli" rel="noopener noreferrer"&gt;@gas-plugin/cli&lt;/a&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>typescript</category>
      <category>vite</category>
      <category>javascript</category>
      <category>gas</category>
    </item>
    <item>
      <title>clasp 3 Dropped TypeScript — Here's a Vite Plugin for Google Apps Script</title>
      <dc:creator>Tetsuya Wakita</dc:creator>
      <pubDate>Fri, 13 Mar 2026 18:10:42 +0000</pubDate>
      <link>https://forem.com/wakita181009/clasp-3-dropped-typescript-heres-a-vite-plugin-for-google-apps-script-49k8</link>
      <guid>https://forem.com/wakita181009/clasp-3-dropped-typescript-heres-a-vite-plugin-for-google-apps-script-49k8</guid>
      <description>&lt;p&gt;&lt;em&gt;Write standard TypeScript with &lt;code&gt;export&lt;/code&gt;. Build with Vite. Push with clasp.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I built this
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/google/clasp/discussions/980" rel="noopener noreferrer"&gt;clasp 3&lt;/a&gt; removed built-in TypeScript support. The recommendation is to use an external bundler — Google provides &lt;a href="https://github.com/google/aside" rel="noopener noreferrer"&gt;google/aside&lt;/a&gt; for Rollup.&lt;/p&gt;

&lt;p&gt;I wanted Vite. The existing Vite plugins for GAS were either outdated (Rhino-era transforms like arrow function conversion) or didn't support web apps. So I built &lt;strong&gt;&lt;a href="https://github.com/wakita181009/gas-vite-plugin" rel="noopener noreferrer"&gt;gas-vite-plugin&lt;/a&gt;&lt;/strong&gt; — a minimal Vite plugin that only does what Vite can't do natively for GAS.&lt;/p&gt;




&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; gas-vite-plugin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Basic usage
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;gasPlugin&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gas-vite-plugin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;vite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;gasPlugin&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;lib&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;src/main.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;formats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;fileName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Code.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/main.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onOpen&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;SpreadsheetApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUi&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMenu&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;My Menu&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Run&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;myFunction&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addToUi&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;myFunction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;SpreadsheetApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getActiveSpreadsheet&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hello from Vite!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx vite build   &lt;span class="c"&gt;# → dist/Code.js (exports stripped) + dist/appsscript.json&lt;/span&gt;
npx clasp push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plugin strips &lt;code&gt;export&lt;/code&gt; keywords, copies &lt;code&gt;appsscript.json&lt;/code&gt;, and sets GAS-safe defaults (no minification, no code splitting). That's the zero-config experience.&lt;/p&gt;




&lt;h2&gt;
  
  
  Features
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Export stripping&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Removes &lt;code&gt;export&lt;/code&gt; so functions are callable by GAS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Manifest copy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Copies &lt;code&gt;appsscript.json&lt;/code&gt; to dist automatically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;File include&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Copies HTML/CSS flat to dist for web apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tree-shake protection&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Keeps functions that GAS calls by string name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GAS-safe defaults&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No minification, no code splitting, V8 assumed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Options
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;gasPlugin&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;src/appsscript.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// default&lt;/span&gt;
  &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;src/**/*.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;       &lt;span class="c1"&gt;// copy files flat to dist&lt;/span&gt;
  &lt;span class="na"&gt;globals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;processData&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;         &lt;span class="c1"&gt;// protect from tree-shaking&lt;/span&gt;
  &lt;span class="na"&gt;autoGlobals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                &lt;span class="c1"&gt;// auto-protect exports (default)&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;globals&lt;/code&gt; — when tree-shaking removes too much
&lt;/h3&gt;

&lt;p&gt;GAS calls functions by string name (menu handlers, triggers, &lt;code&gt;google.script.run&lt;/code&gt;). If a function isn't exported and isn't referenced in code, Vite removes it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onOpen&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;SpreadsheetApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUi&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMenu&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Tools&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Process&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;processData&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// string reference — Vite can't see this&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addToUi&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processData&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* removed by tree-shaking */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fix: add it to &lt;code&gt;globals&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;gasPlugin&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;globals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;processData&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exported functions are protected automatically (&lt;code&gt;autoGlobals: true&lt;/code&gt; by default).&lt;/p&gt;




&lt;h2&gt;
  
  
  Web app support
&lt;/h2&gt;

&lt;p&gt;GAS web apps need HTML files and backend functions callable via &lt;code&gt;google.script.run&lt;/code&gt;. Use &lt;code&gt;include&lt;/code&gt; for files and &lt;code&gt;globals&lt;/code&gt; for the backend functions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.ts&lt;/span&gt;
&lt;span class="nf"&gt;gasPlugin&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;src/**/*.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;globals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;getData&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;saveData&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/main.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;doGet&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;HtmlService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHtmlOutputFromFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;index&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;setTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;My App&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getData&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;SpreadsheetApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getActiveSpreadsheet&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getActiveSheet&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getDataRange&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getValues&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- src/index.html — copied flat to dist --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;script&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withSuccessHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dist/
├── Code.js          # exports stripped
├── appsscript.json  # auto-copied
└── index.html       # flat-copied via include
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Files are flattened — &lt;code&gt;src/views/sidebar.html&lt;/code&gt; → &lt;code&gt;dist/sidebar.html&lt;/code&gt;. GAS doesn't support subdirectories.&lt;/p&gt;




&lt;h2&gt;
  
  
  Recommended project layout
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;your-gas-project/
├── src/
│   ├── main.ts           # entry point
│   ├── utils.ts          # modules — import normally
│   ├── appsscript.json   # GAS manifest
│   └── index.html        # for web apps
├── dist/                  # → clasp push from here
├── vite.config.ts
├── .clasp.json            # rootDir: "dist"
└── package.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/wakita181009/gas-vite-plugin" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/strong&gt; — source, examples, issues&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.npmjs.com/package/gas-vite-plugin" rel="noopener noreferrer"&gt;npm&lt;/a&gt;&lt;/strong&gt; — &lt;code&gt;npm install -D gas-vite-plugin&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>typescript</category>
      <category>gas</category>
      <category>vite</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Made an LLM Add Features to Its Own Code — Classic Spring Boot vs Clean Architecture</title>
      <dc:creator>Tetsuya Wakita</dc:creator>
      <pubDate>Mon, 09 Mar 2026 17:58:47 +0000</pubDate>
      <link>https://forem.com/wakita181009/i-made-an-llm-add-features-to-its-own-code-classic-spring-boot-vs-clean-architecture-l6l</link>
      <guid>https://forem.com/wakita181009/i-made-an-llm-add-features-to-its-own-code-classic-spring-boot-vs-clean-architecture-l6l</guid>
      <description>&lt;p&gt;&lt;em&gt;In Part 0, both implementations worked. In Part 1, the LLM modified its own code — and the results challenged my assumptions about both architectures.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 1: feature addition
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://dev.to/wakita181009/i-made-an-llm-build-the-same-app-twice-classic-spring-boot-vs-clean-architecture-9hl"&gt;Part 0&lt;/a&gt;, I had Claude Code generate two complete SaaS billing systems from scratch — one in classic Spring Boot, one in Clean Architecture with Arrow-kt. Both worked. Both had ~89% coverage. The structural differences were visible but untested.&lt;/p&gt;

&lt;p&gt;Phase 1 tests what happens next. I gave the LLM three new feature specs and pointed it at the Phase 0 codebase:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Add-ons&lt;/strong&gt; — flat-rate and per-seat billing types, attached to subscriptions with proration on mid-cycle changes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seat management&lt;/strong&gt; — per-seat pricing with minimum/maximum constraints, prorated charges on seat count changes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credit notes&lt;/strong&gt; — full and partial refunds, with two application methods (refund to payment method or account credit)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same orchestrator agent. Same process. The only difference: this time, the LLM read existing code before writing new code.&lt;/p&gt;

&lt;p&gt;I expected Clean to scale cleanly and Classic to degrade predictably. The actual results were messier — and more interesting.&lt;/p&gt;




&lt;h2&gt;
  
  
  Generation time: the gap disappeared
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Classic&lt;/th&gt;
&lt;th&gt;Clean&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Phase 0&lt;/strong&gt; (greenfield)&lt;/td&gt;
&lt;td&gt;18m 15s&lt;/td&gt;
&lt;td&gt;32m 2s&lt;/td&gt;
&lt;td&gt;1.75x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Phase 1&lt;/strong&gt; (feature add)&lt;/td&gt;
&lt;td&gt;28m 57s&lt;/td&gt;
&lt;td&gt;29m 20s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.01x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is the most surprising number in the entire experiment.&lt;/p&gt;

&lt;p&gt;In Phase 0, Clean took 1.75x longer — the orchestrator had to coordinate 6 agents across 5 modules with strict dependency ordering, generating 115 files from a detailed layer sketch. Classic's simpler pipeline finished in half the time.&lt;/p&gt;

&lt;p&gt;In Phase 1, they're 23 seconds apart. The existing codebase became the context. The LLM didn't need the layer sketch to figure out where files go — it could read the project structure and follow the patterns already established. The upfront cost of Clean's architecture was paid in Phase 0. Phase 1 spent it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Classic: the LLM learned from its own code
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The most interesting finding
&lt;/h3&gt;

&lt;p&gt;In Phase 0, the LLM created one &lt;code&gt;SubscriptionService&lt;/code&gt; with 457 lines and 9 responsibilities. I expected Phase 1 to make it worse — to stuff add-on logic, seat management, and credit notes into the same class.&lt;/p&gt;

&lt;p&gt;Instead, the LLM created three new services:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// classic/service/AddOnService.kt — 257 lines&lt;/span&gt;
&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AddOnService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;subscriptionRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;addOnRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AddOnRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;subscriptionAddOnRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionAddOnRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;invoiceRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;InvoiceRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;paymentGateway&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentGateway&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Clock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// classic/service/SeatService.kt — 220 lines&lt;/span&gt;
&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SeatService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;subscriptionRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;subscriptionAddOnRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionAddOnRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;invoiceRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;InvoiceRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;paymentGateway&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentGateway&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Clock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// classic/service/CreditNoteService.kt — 128 lines&lt;/span&gt;
&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreditNoteService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LLM saw the existing 457-line service and chose not to add to it. It created separate services for separate domains. This is genuine adaptation — the Phase 0 code taught the LLM that this project uses one service per domain concept.&lt;/p&gt;

&lt;p&gt;This matters. Nobody added a rule saying "create separate services." Nobody modified the &lt;code&gt;.claude/&lt;/code&gt; context. The LLM read the existing codebase and adapted its behavior. The implication is significant: &lt;strong&gt;good existing code can guide an LLM even without explicit architectural constraints.&lt;/strong&gt; Classic's Phase 0 output, despite its flaws, was good enough to teach the LLM better decomposition in Phase 1.&lt;/p&gt;

&lt;h3&gt;
  
  
  What didn't improve
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;SubscriptionService still grew.&lt;/strong&gt; Despite decomposition into new services, &lt;code&gt;SubscriptionService&lt;/code&gt; went from 457 to 635 lines. The &lt;code&gt;processRenewal()&lt;/code&gt; method absorbed per-seat pricing calculations, add-on charge aggregation, and account credit application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// classic/service/SubscriptionService.kt — processRenewal() grew to include:&lt;/span&gt;
&lt;span class="c1"&gt;// - Per-seat plan charge calculation&lt;/span&gt;
&lt;span class="c1"&gt;// - Add-on charge iteration and line item generation&lt;/span&gt;
&lt;span class="c1"&gt;// - Account credit balance application&lt;/span&gt;
&lt;span class="c1"&gt;// - 9 original responsibilities + 3 new ones&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The new services handle creation and modification of add-ons, seats, and credit notes. But renewal — the operation that touches everything — still lives in &lt;code&gt;SubscriptionService&lt;/code&gt;. The LLM couldn't extract it because the method depends on repositories from all three new domains.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proration logic is duplicated.&lt;/strong&gt; The same day-ratio calculation appears in &lt;code&gt;SeatService.updateSeatCount()&lt;/code&gt;, &lt;code&gt;AddOnService.attachAddOn()&lt;/code&gt;, and &lt;code&gt;SubscriptionService.processRenewal()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This pattern appears in 3 different services:&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;daysRemaining&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ChronoUnit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DAYS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;between&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;currentPeriodEnd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;totalDays&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ChronoUnit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DAYS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;between&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currentPeriodStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;currentPeriodEnd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;prorationFactor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;daysRemaining&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;divide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;totalDays&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;RoundingMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HALF_UP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;proratedAmount&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;multiply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prorationFactor&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;setScale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;RoundingMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HALF_UP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each service calculates proration independently. The LLM generated each method in isolation and didn't refactor common logic afterward. (Note: as we'll see, clean has the same problem — the LLM duplicates cross-cutting calculations regardless of architecture.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exception explosion.&lt;/strong&gt; Phase 0 had 8 exception types. Phase 1 added 13 more:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AddOnNotFoundException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ServiceException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DuplicateAddOnException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;addonId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ServiceException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PerSeatAddOnOnNonPerSeatPlanException&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ServiceException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SeatCountOutOfRangeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ServiceException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SameSeatCountException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ServiceException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.)&lt;/span&gt;
&lt;span class="c1"&gt;// ... 8 more&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each exception is a flat class extending &lt;code&gt;ServiceException&lt;/code&gt;. There's no hierarchy grouping add-on errors separate from seat errors. When a controller catches &lt;code&gt;ServiceException&lt;/code&gt;, it handles all 21 types through the same &lt;code&gt;GlobalExceptionHandler&lt;/code&gt; — the HTTP status code is embedded in each exception, which works, but provides no compile-time guarantee that all cases are handled.&lt;/p&gt;




&lt;h2&gt;
  
  
  Clean: use cases added like Lego blocks
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The structure held
&lt;/h3&gt;

&lt;p&gt;Phase 1 added exactly 4 new use cases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Each use case: single responsibility, explicit error type, Either return&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AttachAddOnUseCaseImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AttachAddOnUseCase&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AttachAddOnCommand&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AttachAddOnError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionAddOn&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DetachAddOnUseCaseImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DetachAddOnUseCase&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subscriptionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;addOnId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DetachAddOnError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UpdateSeatCountUseCaseImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;UpdateSeatCountUseCase&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;UpdateSeatCountCommand&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UpdateSeatCountError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Subscription&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;IssueCreditNoteUseCaseImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;IssueCreditNoteUseCase&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;IssueCreditNoteCommand&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;IssueCreditNoteError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CreditNote&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each use case has its own sealed error type. &lt;code&gt;AttachAddOnError&lt;/code&gt; has 12 variants. &lt;code&gt;UpdateSeatCountError&lt;/code&gt; has 11 variants. The compiler enforces exhaustive handling at every call site.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;AttachAddOnError&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ApplicationError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;InvalidInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AttachAddOnError&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionNotFound&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AttachAddOnError&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;NotActive&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AttachAddOnError&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;AddOnNotFound&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AttachAddOnError&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;CurrencyMismatch&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AttachAddOnError&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;TierIncompatible&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AttachAddOnError&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;PerSeatOnNonPerSeatPlan&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AttachAddOnError&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;DuplicateAddOn&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AttachAddOnError&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;AddOnLimitReached&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AttachAddOnError&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;PaymentFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AttachAddOnError&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;Domain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DomainError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AttachAddOnError&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;Internal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;cause&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AttachAddOnError&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New domain models (&lt;code&gt;AddOn&lt;/code&gt;, &lt;code&gt;SubscriptionAddOn&lt;/code&gt;, &lt;code&gt;CreditNote&lt;/code&gt;) follow the same value object pattern. New ports (&lt;code&gt;AddOnQueryPort&lt;/code&gt;, &lt;code&gt;SubscriptionAddOnCommandQueryPort&lt;/code&gt;, &lt;code&gt;CreditNoteCommandQueryPort&lt;/code&gt;) maintain the dependency direction. New infrastructure adapters implement the ports. The existing code didn't need to change its structure to accommodate new features — new features slotted into the existing structure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where clean showed cracks
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The backward-compatibility hack.&lt;/strong&gt; &lt;code&gt;ProcessRenewalUseCase&lt;/code&gt; needed to incorporate add-on charges into renewal invoices. The LLM's solution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProcessRenewalUseCaseImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="c1"&gt;// ... existing dependencies ...&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;subscriptionAddOnCommandQueryPort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionAddOnCommandQueryPort&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;addOnQueryPort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AddOnQueryPort&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ProcessRenewalUseCase&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subscriptionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProcessRenewalError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Subscription&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;either&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;addOnCharges&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subscriptionAddOnCommandQueryPort&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;let&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
            &lt;span class="c1"&gt;// calculate add-on charges only if port is available&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="nf"&gt;emptyList&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Optional dependencies with &lt;code&gt;?.let&lt;/code&gt; null checks. This is a code smell in Clean Architecture — a use case should declare all its dependencies explicitly. The LLM chose backward compatibility over correctness, probably because the existing tests for &lt;code&gt;ProcessRenewalUseCase&lt;/code&gt; don't provide add-on ports. Breaking those tests would mean rewriting them, which the LLM avoided.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proration duplication — the same problem as classic.&lt;/strong&gt; Phase 0's &lt;code&gt;PlanChangeUseCase&lt;/code&gt; delegates to a &lt;code&gt;ProrationDomainService&lt;/code&gt; in the domain layer. But the Phase 1 use cases (&lt;code&gt;AttachAddOnUseCase&lt;/code&gt;, &lt;code&gt;DetachAddOnUseCase&lt;/code&gt;, &lt;code&gt;UpdateSeatCountUseCase&lt;/code&gt;) all calculate proration inline — the same &lt;code&gt;totalDays / daysRemaining&lt;/code&gt; pattern, duplicated across four use cases. The LLM didn't reuse the existing domain service. Use case isolation, which prevents god services, also prevents the LLM from seeing shared logic across use cases. Architecture constrains bloat but doesn't prevent duplication.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;252-line use case.&lt;/strong&gt; &lt;code&gt;UpdateSeatCountUseCaseImpl&lt;/code&gt; handles both seat increases (charge proration) and seat decreases (credit issuance), including per-seat add-on proration for both directions. At 252 lines, it's the largest use case — still smaller than classic's &lt;code&gt;SubscriptionService&lt;/code&gt;, but pushing the boundary of "one use case, one responsibility."&lt;/p&gt;




&lt;h2&gt;
  
  
  The numbers: Phase 0 → Phase 1
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Code volume
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Classic P0&lt;/th&gt;
&lt;th&gt;Classic P1&lt;/th&gt;
&lt;th&gt;Clean P0&lt;/th&gt;
&lt;th&gt;Clean P1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Source files (total)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;td&gt;69&lt;/td&gt;
&lt;td&gt;80+&lt;/td&gt;
&lt;td&gt;173&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Production lines&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;787&lt;/td&gt;
&lt;td&gt;2,973&lt;/td&gt;
&lt;td&gt;1,225&lt;/td&gt;
&lt;td&gt;6,274&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Test lines&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;5,775&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;4,825&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Largest file&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;457 lines&lt;/td&gt;
&lt;td&gt;635 lines&lt;/td&gt;
&lt;td&gt;120 lines&lt;/td&gt;
&lt;td&gt;252 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Phase 0 line counts covered production source only. Phase 1 includes production + test breakdown. jOOQ generated code (in &lt;code&gt;build/&lt;/code&gt;) is excluded from all counts.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Both codebases roughly doubled in file count. The largest file metric is not a full maintainability measure, but it's a useful proxy for &lt;em&gt;responsibility concentration&lt;/em&gt; — how much logic the LLM packs into a single place. Classic's largest file grew 39% (457 → 635). Clean's grew 110% but from a much lower base (120 → 252, still below the size where single-file comprehension starts to get harder).&lt;/p&gt;

&lt;h3&gt;
  
  
  Test results
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Classic P0&lt;/th&gt;
&lt;th&gt;Classic P1&lt;/th&gt;
&lt;th&gt;Clean P0&lt;/th&gt;
&lt;th&gt;Clean P1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Test count&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;155&lt;/td&gt;
&lt;td&gt;291&lt;/td&gt;
&lt;td&gt;167&lt;/td&gt;
&lt;td&gt;288&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cold build + test&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4s&lt;/td&gt;
&lt;td&gt;5s&lt;/td&gt;
&lt;td&gt;9s&lt;/td&gt;
&lt;td&gt;10s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Warm build + test&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3s&lt;/td&gt;
&lt;td&gt;4s&lt;/td&gt;
&lt;td&gt;3s&lt;/td&gt;
&lt;td&gt;8s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Test counts are almost identical (291 vs 288). The LLM generated comprehensive tests for both architectures. In this setup, Clean's warm build time rose from 3s to 8s, likely reflecting the growing overhead of a larger multi-module Gradle build.&lt;/p&gt;

&lt;h3&gt;
  
  
  Coverage
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Classic P0&lt;/th&gt;
&lt;th&gt;Classic P1&lt;/th&gt;
&lt;th&gt;Clean P0&lt;/th&gt;
&lt;th&gt;Clean P1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Line coverage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;89.7%&lt;/td&gt;
&lt;td&gt;94.6%&lt;/td&gt;
&lt;td&gt;87.7%&lt;/td&gt;
&lt;td&gt;90.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Branch coverage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;83.4%&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;64.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Classic's line coverage improved from 89.7% to 94.6%. The new services (AddOnService, SeatService, CreditNoteService) are well-tested — possibly because the LLM generated them fresh with tests, rather than modifying existing code.&lt;/p&gt;

&lt;p&gt;Clean's branch coverage at 64.7% deserves honest scrutiny. One-third of all branch paths are untested. The sealed interface error types define variants like &lt;code&gt;Internal(cause: String)&lt;/code&gt; and &lt;code&gt;Domain(error: DomainError)&lt;/code&gt; that no test exercises. In Part 0, I argued that clean's uncovered paths are "named and enumerable" — you can grep for them. That's true. But Phase 1 reveals the practical cost: &lt;strong&gt;the LLM defines exhaustive error types because the type system demands it, then doesn't write exhaustive tests because nothing demands that.&lt;/strong&gt; The architecture pays the definition cost but doesn't automatically collect the testing benefit. The types exist as compile-time documentation, not runtime guarantees — unless someone (human or LLM) writes the tests to match.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Phase 1 reveals that Phase 0 couldn't
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Patterns across both architectures
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Existing code is the strongest context.&lt;/strong&gt; In classic, the LLM read a 457-line god service and decided &lt;em&gt;not&lt;/em&gt; to make it worse — it created three new services unprompted. In clean, it followed the existing use case pattern without needing the layer sketch. The &lt;code&gt;.claude/&lt;/code&gt; rules and agent configurations matter most in Phase 0. By Phase 1, the codebase itself teaches the LLM what to do. This also explains the generation time convergence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Architecture constrains structure but not duplication.&lt;/strong&gt; Both implementations duplicated proration logic — classic across three services, clean across four use cases. Phase 0's &lt;code&gt;PlanChangeUseCase&lt;/code&gt; delegates to a &lt;code&gt;ProrationDomainService&lt;/code&gt;, but Phase 1's new use cases all calculate proration inline. The LLM didn't reuse the existing domain service. It generates each unit independently and doesn't refactor across boundaries — whether those boundaries are services or use cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Type safety without test safety is incomplete.&lt;/strong&gt; Clean's branch coverage dropped to 64.7%. The type system demanded exhaustive error definitions; the LLM complied. But nothing demanded exhaustive tests, and the LLM didn't write them. Meanwhile, classic's &lt;code&gt;processRenewal()&lt;/code&gt; grew from 9 to 12 responsibilities (457 → 635 lines) because there's no mechanism to prevent a method from becoming the integration point for the entire domain. Clean constrains bloat structurally; classic relies on the LLM making good local decisions — which it sometimes does (new services) and sometimes doesn't (growing &lt;code&gt;processRenewal&lt;/code&gt;). But neither architecture made the LLM write better tests.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the data suggests — and what it doesn't
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What the numbers show
&lt;/h3&gt;

&lt;p&gt;Two phases of data show clear patterns:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generation time converged.&lt;/strong&gt; Phase 0: 1.75x gap. Phase 1: 1.01x gap. Existing code as context reduced Clean's coordination overhead and increased Classic's reading time. Whether this trend continues, stabilizes, or reverses at Phase N is unknown — we have two data points, not a curve.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Classic's largest file grew; Clean's stayed bounded.&lt;/strong&gt; 457 → 635 lines vs 120 → 252 lines. Classic's growth came from &lt;code&gt;processRenewal()&lt;/code&gt; absorbing cross-domain logic. Clean's growth came from a complex use case (seat + add-on proration), but each use case remains independent. The &lt;em&gt;mechanism&lt;/em&gt; differs: Classic grows by accretion in existing files; Clean grows by adding new files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplication is architecture-independent.&lt;/strong&gt; Both implementations duplicated proration logic across multiple locations. The LLM doesn't refactor across boundaries — whether those boundaries are services (classic) or use cases (clean). This is arguably the most important finding: &lt;strong&gt;the LLM's weakest capability is recognizing shared logic across independently generated code units.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The LLM adapts to existing code patterns.&lt;/strong&gt; Classic's self-decomposition into new services was unprompted. Clean's consistent use case structure was self-reinforcing. Existing code is a stronger guide than configuration files.&lt;/p&gt;

&lt;h3&gt;
  
  
  The trade-off table
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Classic&lt;/th&gt;
&lt;th&gt;Clean&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Greenfield speed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;18m (fast)&lt;/td&gt;
&lt;td&gt;32m (slow)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Feature-add speed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;29m&lt;/td&gt;
&lt;td&gt;29m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Code readability&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Immediately familiar&lt;/td&gt;
&lt;td&gt;Requires architecture knowledge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Error handling&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Flat exceptions (easy to add, easy to miss)&lt;/td&gt;
&lt;td&gt;Sealed types (verbose, compiler-enforced)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Duplication&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Across services&lt;/td&gt;
&lt;td&gt;Across use cases (same problem)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Max file size&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Growing (457 → 635)&lt;/td&gt;
&lt;td&gt;Bounded (120 → 252)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Branch coverage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;83.4%&lt;/td&gt;
&lt;td&gt;64.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Upfront investment&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;High (multi-module, tooling, rules)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  A hypothesis, not a conclusion
&lt;/h3&gt;

&lt;p&gt;Two data points don't make a trend. But they suggest a hypothesis worth testing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Architecture may function as a scaling constraint on LLM-generated code.&lt;/strong&gt; Clean's explicit boundaries prevent the &lt;em&gt;structural&lt;/em&gt; problems that compound over time (god services, file bloat, implicit dependencies). Classic's flexibility allows the LLM to adapt (new services), but also allows it to accumulate debt (growing integration points, flat error hierarchies).&lt;/p&gt;

&lt;p&gt;However, architecture does &lt;em&gt;not&lt;/em&gt; solve the LLM's core limitation — &lt;strong&gt;cross-boundary refactoring.&lt;/strong&gt; Both implementations duplicated proration logic. Both missed opportunities to extract shared abstractions. Clean's sealed types went untested at the same rate as Classic's exception paths. The LLM fills each container correctly but doesn't optimize across containers.&lt;/p&gt;

&lt;p&gt;The question for a real project isn't "which architecture should I pick?" It's a compound question:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;How often will features be added?&lt;/strong&gt; More features favor explicit boundaries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How much do features interact?&lt;/strong&gt; More interaction creates cross-cutting concerns that stress any architecture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What is the team's tolerance for boilerplate?&lt;/strong&gt; In this experiment, Clean carried a roughly 2.5x file-count overhead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What is the LLM's role?&lt;/strong&gt; If the LLM generates initial code and humans maintain it, Classic's familiarity wins. If the LLM generates and extends code continuously, Clean's self-documenting structure wins.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Architecture is not a prompt. It's the shape of the container the LLM fills — and the shape determines what compounds and what stays constant as the codebase grows.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;The full source is on GitHub: &lt;a href="https://github.com/wakita181009/clean-architecture-kotlin-in-practice/tree/main/classic" rel="noopener noreferrer"&gt;classic/&lt;/a&gt; | &lt;a href="https://github.com/wakita181009/clean-architecture-kotlin-in-practice/tree/main/clean" rel="noopener noreferrer"&gt;clean/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>kotlin</category>
      <category>springboot</category>
      <category>ai</category>
    </item>
    <item>
      <title>I Made an LLM Build the Same App Twice — Classic Spring Boot vs Clean Architecture</title>
      <dc:creator>Tetsuya Wakita</dc:creator>
      <pubDate>Mon, 09 Mar 2026 17:07:40 +0000</pubDate>
      <link>https://forem.com/wakita181009/i-made-an-llm-build-the-same-app-twice-classic-spring-boot-vs-clean-architecture-9hl</link>
      <guid>https://forem.com/wakita181009/i-made-an-llm-build-the-same-app-twice-classic-spring-boot-vs-clean-architecture-9hl</guid>
      <description>&lt;p&gt;&lt;em&gt;I gave Claude Code identical business requirements with two different architectural contexts. The generated code told me more about architecture's value than any textbook could.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The experiment
&lt;/h2&gt;

&lt;p&gt;I set up a controlled experiment in two phases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Phase 0 — Full-scratch generation.&lt;/strong&gt; Give the LLM a spec and an empty project. It builds everything from zero. This tests how architecture shapes greenfield code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 1 — Feature addition.&lt;/strong&gt; Give the LLM an existing codebase (the Phase 0 output) and a new feature spec. It reads, modifies, and extends. This tests how architecture holds up under change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same business domain for both: a SaaS subscription billing system. The spec covers plan management with tiered pricing (free / starter / professional / enterprise), the full subscription lifecycle (trial → active → paused → canceled → expired), usage-based metering with per-plan limits, proration calculations on mid-cycle plan changes, invoice generation with line items and discount application, and payment processing with retry logic and grace periods.&lt;/p&gt;

&lt;p&gt;Same spec files. Two different architectural contexts.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Classic&lt;/strong&gt;: single-module Spring Boot, layered architecture (controller → service → repository → model), JPA, JUnit 5, exception-based error handling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean&lt;/strong&gt;: 5-module Clean Architecture, Arrow-kt &lt;code&gt;Either&lt;/code&gt;, CQRS, jOOQ, Kotest, sealed interface error types&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This article covers Phase 0. The orchestrator agent read the specs, produced a layer sketch, and spawned implementer agents to generate every file from zero. No human edits. No iterative refinement. The only difference was the &lt;code&gt;.claude/&lt;/code&gt; directory — the architectural context the LLM received.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "context" means for an LLM
&lt;/h2&gt;

&lt;p&gt;Most discussions about AI-generated code focus on the prompt. But for a codebase-scale task, the prompt is the least important input. What matters is the &lt;strong&gt;context&lt;/strong&gt; — the accumulated knowledge the LLM has access to when making decisions.&lt;/p&gt;

&lt;p&gt;For classic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;classic/.claude/
├── CLAUDE.md            # Architecture rules, package conventions
├── rules/               # 3 files: JPA patterns, Spring patterns, testing
├── agents/              # 5 agents: orchestrator, entity, service, api, tester
├── skills/              # TDD patterns
├── specs/               # 4 spec files (shared)
└── layer-sketch.md      # 45 files to create
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;clean/.claude/
├── CLAUDE.md            # Layer rules, CQRS structure, forbidden imports
├── rules/               # 2 files: Arrow Either style, test conventions
├── agents/              # 6 agents: orchestrator, domain, app, infra, presentation, tester
├── skills/              # 5 skills: CA patterns, FP patterns, Arrow-kt, jOOQ, TDD
├── specs/               # 4 spec files (shared)
└── layer-sketch.md      # 115 files to create
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the difference isn't just the &lt;code&gt;.claude/&lt;/code&gt; directory. The clean project also requires &lt;strong&gt;infrastructure that exists before the LLM starts generating&lt;/strong&gt;: a multi-module Gradle setup with dependency constraints between modules, custom detekt rules (&lt;code&gt;ForbiddenLayerImportRule&lt;/code&gt;, &lt;code&gt;NoThrowOutsidePresentationRule&lt;/code&gt;), jOOQ code generation from DDL, and Kover coverage thresholds. Classic needs none of this — a single &lt;code&gt;build.gradle.kts&lt;/code&gt; with Spring Boot starters is enough.&lt;/p&gt;

&lt;p&gt;The classic context teaches the LLM &lt;strong&gt;how to write code&lt;/strong&gt; — JPA annotation patterns, &lt;code&gt;@Transactional&lt;/code&gt; placement, exception hierarchy design. The clean context teaches the LLM &lt;strong&gt;where code cannot go&lt;/strong&gt; — forbidden imports per layer, mandatory &lt;code&gt;Either&lt;/code&gt; return types, whitelist-based constraints — backed by tooling that enforces those boundaries at build time.&lt;/p&gt;

&lt;p&gt;This is the fundamental difference. Classic gives guidelines. Clean gives boundaries with teeth.&lt;/p&gt;




&lt;h2&gt;
  
  
  The generated code: classic
&lt;/h2&gt;

&lt;p&gt;The LLM produced 33 source files in a single module. Here's what it did well and where it broke down.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the LLM got right
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Rich domain models.&lt;/strong&gt; The &lt;code&gt;Money&lt;/code&gt; class handles currency-safe arithmetic with operator overloading. &lt;code&gt;SubscriptionStatus&lt;/code&gt; and &lt;code&gt;InvoiceStatus&lt;/code&gt; implement state machine transitions with &lt;code&gt;canTransitionTo()&lt;/code&gt; guards. These patterns came directly from the spec + the &lt;code&gt;jpa-kotlin.md&lt;/code&gt; rule file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// classic/model/Money.kt&lt;/span&gt;
&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Currency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;operator&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;plus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"Currency mismatch"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Exception hierarchy.&lt;/strong&gt; The LLM created a &lt;code&gt;ServiceException&lt;/code&gt; base class with HTTP status codes and a &lt;code&gt;GlobalExceptionHandler&lt;/code&gt; that maps each exception type to the correct response. Clean, predictable, consistent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where classic broke down
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The 457-line service.&lt;/strong&gt; &lt;code&gt;SubscriptionService&lt;/code&gt; handles creation, plan changes, pauses, resumes, cancellations, renewals, and discount management — all in one class. The LLM followed the most common Spring Boot pattern it's seen: one service per entity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// classic/service/SubscriptionService.kt — 457 lines&lt;/span&gt;
&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;subscriptionRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;planRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PlanRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;invoiceRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;InvoiceRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;usageRecordRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;UsageRecordRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;discountRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DiscountRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// 5 repositories&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;paymentGateway&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentGateway&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Clock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;createSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.):&lt;/span&gt; &lt;span class="nc"&gt;Subscription&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* 50 lines */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;changePlan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.):&lt;/span&gt; &lt;span class="nc"&gt;Subscription&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* 80 lines */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;pauseSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.):&lt;/span&gt; &lt;span class="nc"&gt;Subscription&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* 25 lines */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;resumeSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.):&lt;/span&gt; &lt;span class="nc"&gt;Subscription&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* 20 lines */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;cancelSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.):&lt;/span&gt; &lt;span class="nc"&gt;Subscription&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* 35 lines */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;processRenewal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.):&lt;/span&gt; &lt;span class="nc"&gt;Subscription&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* 90 lines — 9 responsibilities */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nobody told the LLM to create one service with nine responsibilities. But nobody told it not to, either. The classic context defines a &lt;code&gt;service/&lt;/code&gt; package and lists &lt;code&gt;SubscriptionService&lt;/code&gt; as the class to create. The LLM filled it with everything subscription-related — because that's the statistical norm in Spring Boot codebases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardcoded discount resolution — in the wrong place.&lt;/strong&gt; The spec mentions discount codes but doesn't specify a database lookup. Both implementations hardcoded the same &lt;code&gt;"WELCOME20"&lt;/code&gt; string. The difference is &lt;em&gt;where&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;In classic, the hardcode lives inside &lt;code&gt;SubscriptionService&lt;/code&gt; as a private method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// classic/service/SubscriptionService.kt&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;resolveDiscount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Discount&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"WELCOME20"&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Discount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PERCENTAGE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"20"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.)&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;InvalidDiscountCodeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In clean, the same hardcode lives in &lt;code&gt;DiscountRepositoryImpl&lt;/code&gt; in the infrastructure layer, behind a &lt;code&gt;DiscountCodePort&lt;/code&gt; interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// clean/infrastructure/.../DiscountRepositoryImpl.kt&lt;/span&gt;
&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.):&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DomainError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Discount&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;either&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"WELCOME20"&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Discount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.)&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are stubs. But when the time comes to replace the hardcode with a database lookup, classic requires modifying &lt;code&gt;SubscriptionService&lt;/code&gt; — the 457-line class that owns all subscription logic. Clean requires modifying only the infrastructure adapter. The use case never knows the difference, because it depends on &lt;code&gt;DiscountCodePort&lt;/code&gt;, not the concrete implementation. The layer sketch defined this port upfront; the LLM didn't choose it — the architecture did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invoice generation duplicated.&lt;/strong&gt; The &lt;code&gt;changePlan()&lt;/code&gt; method and &lt;code&gt;processRenewal()&lt;/code&gt; method both construct &lt;code&gt;Invoice&lt;/code&gt; objects with line items. The logic is nearly identical. The LLM generated each method independently, and since there's no factory pattern in the context, it copy-pasted the construction logic.&lt;/p&gt;




&lt;h2&gt;
  
  
  The generated code: clean
&lt;/h2&gt;

&lt;p&gt;The LLM produced 80+ source files across 5 modules. The same spec, but a fundamentally different result.&lt;/p&gt;

&lt;h3&gt;
  
  
  The architecture constrained the output
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;One use case, one file.&lt;/strong&gt; The clean context defines 8 command use cases and 3 query use cases — each as a separate interface and implementation. The LLM couldn't create a 457-line service because the structure doesn't have a place for one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// clean/application/command/usecase/PlanChangeUseCaseImpl.kt — 85 lines&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PlanChangeUseCaseImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;subscriptionCommandQueryPort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionCommandQueryPort&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;planQueryPort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PlanQueryPort&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;paymentGatewayPort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentGatewayPort&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;prorationDomainService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ProrationDomainService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;subscriptionRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;invoiceRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;InvoiceRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;clockPort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ClockPort&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;transactionPort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;TransactionPort&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PlanChangeUseCase&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ChangePlanCommand&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PlanChangeError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Subscription&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;either&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The largest use case is 120 lines. The structure made bloat physically impossible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Either forced exhaustive error handling.&lt;/strong&gt; Every use case returns &lt;code&gt;Either&amp;lt;SpecificError, T&amp;gt;&lt;/code&gt;. The LLM couldn't skip error cases because the sealed interface makes the compiler enforce exhaustiveness:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;PlanChangeError&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ApplicationError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;InvalidInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;field&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PlanChangeError&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionNotFound&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PlanChangeError&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;NotActive&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PlanChangeError&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;SamePlan&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PlanChangeError&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;PlanNotFound&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PlanChangeError&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;CurrencyMismatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PlanChangeError&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;PaymentFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PlanChangeError&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;Domain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DomainError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PlanChangeError&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;Internal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;cause&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PlanChangeError&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In classic, the LLM defined 8 exception types. In clean, it defined 50+ error variants across sealed interfaces. Not because the LLM tried harder — but because the type system demanded it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ports prevented infrastructure leakage.&lt;/strong&gt; The &lt;code&gt;PlanQueryPort&lt;/code&gt; is defined in the application layer. The LLM couldn't import JPA or jOOQ in the use case because the module boundary prevents it. The adapter pattern isn't a recommendation — it's the only way to access external systems.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Defined in application/command/port/&lt;/span&gt;
&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;PaymentGatewayPort&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Money&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;paymentMethod&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentMethod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customerRef&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PaymentError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Implemented in infrastructure/command/adapter/&lt;/span&gt;
&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PaymentGatewayAdapter&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PaymentGatewayPort&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.)&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PaymentResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;.).&lt;/span&gt;&lt;span class="nf"&gt;right&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;// stub&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Where clean showed overhead
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;115 files for the same business logic.&lt;/strong&gt; The clean version has 2.5x more files than classic. Many are thin — a sealed interface with two variants, a port interface with one method, a DTO with five fields. But they exist, and each one had to be generated, placed in the correct module, and wired together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Value object ceremony.&lt;/strong&gt; Every ID type requires a smart constructor returning &lt;code&gt;Either&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@JvmInline&lt;/span&gt;
&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionId&lt;/span&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;companion&lt;/span&gt; &lt;span class="k"&gt;object&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;operator&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionId&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;right&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nc"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InvalidId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SubscriptionId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;left&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are 10 value object types. Classic uses &lt;code&gt;Long&lt;/code&gt; directly. The type safety is real, but so is the boilerplate.&lt;/p&gt;




&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Generation time
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Classic&lt;/th&gt;
&lt;th&gt;Clean&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Phase 0 generation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;18m 15s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;32m 2s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Clean took 1.75x longer to generate. The orchestrator coordinates 6 agents across 5 modules with dependency ordering — domain must finish before application, application before infrastructure and presentation. Classic's 4-agent pipeline runs in a simpler sequence with fewer handoffs. The multi-module coordination, larger layer sketch (115 files vs 45), and the additional Arrow-kt/CQRS patterns all add up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Code volume
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Classic&lt;/th&gt;
&lt;th&gt;Clean&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Source files&lt;/td&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;td&gt;80+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total production lines&lt;/td&gt;
&lt;td&gt;787&lt;/td&gt;
&lt;td&gt;1,225&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Largest file&lt;/td&gt;
&lt;td&gt;457 lines (SubscriptionService)&lt;/td&gt;
&lt;td&gt;120 lines (ProcessRenewalUseCase)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error types defined&lt;/td&gt;
&lt;td&gt;8 exception classes&lt;/td&gt;
&lt;td&gt;50+ sealed interface variants&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Test results
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Classic&lt;/th&gt;
&lt;th&gt;Clean&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Test count&lt;/td&gt;
&lt;td&gt;155&lt;/td&gt;
&lt;td&gt;167&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full build + test (cold)&lt;/td&gt;
&lt;td&gt;4s&lt;/td&gt;
&lt;td&gt;9s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full build + test (warm)&lt;/td&gt;
&lt;td&gt;3s&lt;/td&gt;
&lt;td&gt;3s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test execution time&lt;/td&gt;
&lt;td&gt;2.2s&lt;/td&gt;
&lt;td&gt;1.7s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The build time difference disappears once the Gradle daemon is warm — compilation is the bottleneck, not test execution. Clean's tests actually execute faster (1.7s vs 2.2s) despite having more tests, because domain and application tests run without Spring context startup.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer-by-layer test performance
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Classic:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Tests&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Per test&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model (pure)&lt;/td&gt;
&lt;td&gt;70&lt;/td&gt;
&lt;td&gt;0.027s&lt;/td&gt;
&lt;td&gt;0.4ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service (MockK)&lt;/td&gt;
&lt;td&gt;67&lt;/td&gt;
&lt;td&gt;0.723s&lt;/td&gt;
&lt;td&gt;10.8ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Controller (WebMvcTest)&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;1.45s&lt;/td&gt;
&lt;td&gt;80.6ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Clean:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Module&lt;/th&gt;
&lt;th&gt;Tests&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Per test&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Domain (pure + Kotest)&lt;/td&gt;
&lt;td&gt;85&lt;/td&gt;
&lt;td&gt;0.45s&lt;/td&gt;
&lt;td&gt;5.2ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application (Kotest + MockK)&lt;/td&gt;
&lt;td&gt;61&lt;/td&gt;
&lt;td&gt;2.25s&lt;/td&gt;
&lt;td&gt;36.9ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Presentation (no Spring)&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;1.1s&lt;/td&gt;
&lt;td&gt;52.4ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things stand out. First, clean's presentation tests don't use &lt;code&gt;@WebMvcTest&lt;/code&gt; — they instantiate the controller directly and call methods. The 1.1s is pure Kotest/MockK initialization overhead, not Spring. Second, the Kotest runner adds ~0.9s of startup per module for the first test class. After initialization, subsequent test classes run at ~15ms each.&lt;/p&gt;

&lt;h3&gt;
  
  
  Coverage
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Classic (JaCoCo)&lt;/th&gt;
&lt;th&gt;Clean (Kover)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Line coverage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;89.7%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;87.7%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Production lines&lt;/td&gt;
&lt;td&gt;787&lt;/td&gt;
&lt;td&gt;1,225&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Classic by layer:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Coverage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;92.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service&lt;/td&gt;
&lt;td&gt;90.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Controller&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Clean by module:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Module&lt;/th&gt;
&lt;th&gt;Coverage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Domain&lt;/td&gt;
&lt;td&gt;74.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application&lt;/td&gt;
&lt;td&gt;84.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Presentation&lt;/td&gt;
&lt;td&gt;82.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Coverage numbers are nearly identical — but they measure different things.&lt;/p&gt;

&lt;p&gt;Classic's 89.7% means "89.7% of lines were reached." The missing 10.3% includes exception paths that were never thrown in tests. Those paths exist as &lt;code&gt;throw&lt;/code&gt; statements — invisible to the type system, easy to forget in tests.&lt;/p&gt;

&lt;p&gt;Clean's 87.7% means "87.7% of typed error paths were exercised." The missing 12.3% is visible in the code — it's sealed interface variants like &lt;code&gt;Internal(cause: String)&lt;/code&gt; that tests didn't trigger. The untested paths are &lt;strong&gt;named&lt;/strong&gt; and &lt;strong&gt;enumerated&lt;/strong&gt;. You can grep for them.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the experiment actually shows
&lt;/h2&gt;

&lt;p&gt;Both implementations are functionally equivalent. They handle the same subscription lifecycle — creation, plan changes, pauses, resumes, cancellations, renewals, usage metering, and payment processing. Both compile, both pass tests, both have ~89% coverage. &lt;strong&gt;There is no meaningful difference in what the code does.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The difference is in how the code is shaped.&lt;/p&gt;

&lt;h3&gt;
  
  
  Classic: the LLM's strengths and blind spots
&lt;/h3&gt;

&lt;p&gt;Classic plays to the LLM's greatest advantage: &lt;strong&gt;the sheer volume of training data for conventional patterns.&lt;/strong&gt; Spring Boot is the most common Kotlin/JVM web framework. The LLM has seen millions of &lt;code&gt;@Service&lt;/code&gt; + &lt;code&gt;@Repository&lt;/code&gt; + &lt;code&gt;@RestController&lt;/code&gt; examples. The result is concise, idiomatic code that any Spring developer would recognize immediately.&lt;/p&gt;

&lt;p&gt;The model layer is genuinely good. &lt;code&gt;Money&lt;/code&gt; with operator overloading, &lt;code&gt;SubscriptionStatus&lt;/code&gt; with &lt;code&gt;canTransitionTo()&lt;/code&gt; guards, &lt;code&gt;Invoice&lt;/code&gt; with state machine transitions — these are clean, well-structured patterns that benefit from the LLM's deep familiarity with the ecosystem.&lt;/p&gt;

&lt;p&gt;But the same statistical bias that makes the LLM good at conventional patterns makes it prone to conventional mistakes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Service bloat.&lt;/strong&gt; &lt;code&gt;SubscriptionService&lt;/code&gt; at 457 lines with 9 responsibilities. The LLM follows the "one service per entity" norm — the pattern it's seen most often — even when the complexity demands decomposition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code duplication.&lt;/strong&gt; Invoice construction logic appears in both &lt;code&gt;changePlan()&lt;/code&gt; and &lt;code&gt;processRenewal()&lt;/code&gt;. The LLM generates each method independently. Without an explicit Factory pattern in the context, it doesn't extract shared logic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shortest-path solutions.&lt;/strong&gt; Hardcoded discount resolution instead of a repository lookup. When the spec is underspecified, the LLM takes the minimal implementation — not the extensible one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These aren't bugs. The code works. But they're the kind of structural problems that compound over time as features are added.&lt;/p&gt;

&lt;h3&gt;
  
  
  Clean: boundaries prevent problems, at a cost
&lt;/h3&gt;

&lt;p&gt;Clean architecture constrains the LLM's output through structure rather than instruction. The result is code with clear dependency direction, no bloated classes, and exhaustive error handling — but significantly more of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What boundaries buy you:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No bloat possible.&lt;/strong&gt; The layer sketch defines 8 command use cases as separate files. The LLM can't merge them into a god service because there's no single file to put it in. The largest use case is 120 lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explicit dependencies.&lt;/strong&gt; Every external system access goes through a Port interface defined in the application layer. The LLM can't accidentally couple a use case to jOOQ or JPA — the module boundary prevents the import.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exhaustive error handling.&lt;/strong&gt; Sealed interfaces with &lt;code&gt;Either&lt;/code&gt; force the LLM to define and handle every error variant. Classic has 8 exception types; clean has 50+ error variants. The compiler enforces this, not the prompt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What boundaries cost you:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2.5x more code.&lt;/strong&gt; 1,225 lines across 80+ files vs 787 lines across 33 files. Many clean files are thin wrappers — a port interface with one method, a sealed interface with two variants — but they all need to be generated, placed correctly, and wired together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Longer generation time.&lt;/strong&gt; The clean orchestrator must coordinate 6 agents across 5 modules with dependency ordering (domain → application → infrastructure | presentation). Classic's orchestrator runs 4 agents in a simpler pipeline. The multi-module coordination overhead is real.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Higher context investment.&lt;/strong&gt; 24 configuration files vs 19. The additional skills (Arrow-kt patterns, CA layer rules, FP patterns) and the detailed layer sketch are necessary to produce correct output — but they represent upfront work before the first feature ships.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The core observation
&lt;/h3&gt;

&lt;p&gt;The LLM doesn't "understand" architecture. It follows the path of least resistance within the constraints it's given.&lt;/p&gt;

&lt;p&gt;With classic constraints, the path of least resistance is the &lt;strong&gt;statistically dominant pattern&lt;/strong&gt; — concise, idiomatic, and immediately readable, but carrying the same structural debts (god services, code duplication) that the average Spring Boot project accumulates over time.&lt;/p&gt;

&lt;p&gt;With clean constraints, the path of least resistance is the &lt;strong&gt;structurally enforced pattern&lt;/strong&gt; — no bloat, exhaustive error handling, explicit dependencies, but at the cost of 1.75x generation time and 2.5x more files.&lt;/p&gt;

&lt;p&gt;The LLM didn't write better or worse code in either case. It filled the container it was given.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Architecture is not a prompt. It's the shape of the container the LLM fills.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;But this is Phase 0 — greenfield generation. Both codebases work. Both have high coverage. The structural differences are visible but haven't been tested under pressure. The real question is what happens when the LLM modifies existing code — when the 457-line service needs a tenth responsibility, when the duplicated invoice logic needs a third variant.&lt;/p&gt;




&lt;p&gt;The full source is on GitHub: &lt;a href="https://github.com/wakita181009/clean-architecture-kotlin-in-practice/tree/main/classic" rel="noopener noreferrer"&gt;classic/&lt;/a&gt; | &lt;a href="https://github.com/wakita181009/clean-architecture-kotlin-in-practice/tree/main/clean" rel="noopener noreferrer"&gt;clean/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>architecture</category>
      <category>ai</category>
      <category>cleancode</category>
    </item>
    <item>
      <title>I Made Zod 64x Faster by Compiling Schemas at Build Time</title>
      <dc:creator>Tetsuya Wakita</dc:creator>
      <pubDate>Wed, 04 Mar 2026 14:33:57 +0000</pubDate>
      <link>https://forem.com/wakita181009/i-made-zod-64x-faster-by-compiling-schemas-at-build-time-3e1a</link>
      <guid>https://forem.com/wakita181009/i-made-zod-64x-faster-by-compiling-schemas-at-build-time-3e1a</guid>
      <description>&lt;p&gt;Zod v4 is fast. But what if you could skip schema traversal entirely? I built a compiler that turns your Zod schemas into plain JavaScript validation functions at build time — and it's 2–64x faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Runtime Schema Traversal Is Inherently Slow
&lt;/h2&gt;

&lt;p&gt;Zod is everywhere. With over 100 million weekly npm downloads, it's the de facto standard for runtime validation in TypeScript. The API is beautiful — you define a schema, and you get both runtime validation and static types for free.&lt;/p&gt;

&lt;p&gt;But that elegance has a cost.&lt;/p&gt;

&lt;p&gt;Every time you call &lt;code&gt;.parse()&lt;/code&gt;, Zod walks the entire schema tree at runtime. It checks each node type, applies constraints, collects errors, and constructs the result. For a simple &lt;code&gt;z.string()&lt;/code&gt;, the overhead is negligible. But for a nested object with arrays, unions, and dozens of fields? That tree walk adds up fast.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Every .parse() call traverses this entire tree — every time&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;viewer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="na"&gt;preferences&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;light&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="na"&gt;notifications&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// This traverses ~15 schema nodes on every single call&lt;/span&gt;
&lt;span class="nx"&gt;UserSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters in real production scenarios:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API servers&lt;/strong&gt; validating every incoming request body&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge functions&lt;/strong&gt; where cold-start budgets are measured in milliseconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Form validation&lt;/strong&gt; running on every keystroke on mobile devices&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The schema never changes. But Zod re-discovers its structure on every single parse call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Insight: Schemas Are Static — Validation Can Be Compiled
&lt;/h2&gt;

&lt;p&gt;Here's the key observation: most Zod schemas are defined once at module scope and never change at runtime. The tree walk produces the same sequence of operations every time. It's pure overhead.&lt;/p&gt;

&lt;p&gt;This is exactly the problem compilers solve — move repeated work from runtime to build time.&lt;/p&gt;

&lt;p&gt;What if instead of walking a schema tree at runtime, you could generate a plain function that does the exact same validation — but with zero traversal overhead?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// What Zod does at runtime (simplified):&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parseObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parseString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;array&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parseArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ... recursive traversal continues&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// What AOT compilation produces:&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Expected object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Expected string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;UUID_REGEX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid uuid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Expected string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;String must contain at least 1 character(s)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ... flat, linear, no traversal&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second function does the same thing, but there's no schema tree to walk. No type switches. No recursive calls. Just straight-line validation code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introducing zod-aot
&lt;/h2&gt;

&lt;p&gt;That's what &lt;code&gt;zod-aot&lt;/code&gt; does. It reads your Zod schemas at build time, compiles them into optimized validation functions, and replaces the runtime parsing with generated code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Installation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;zod-aot zod@^4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Usage — wrap with &lt;code&gt;compile()&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;compile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod-aot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;viewer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Same API — .parse(), .safeParse(), .is()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;UserSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In development, &lt;code&gt;compile()&lt;/code&gt; passes through to Zod as-is. In production builds, the bundler plugin replaces it with the generated validation function. Zero code changes to your existing schemas.&lt;/p&gt;

&lt;h3&gt;
  
  
  Build integration
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Vite&lt;/strong&gt; (4 lines):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;zodAot&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod-aot/vite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;zodAot&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;CLI&lt;/strong&gt; for non-bundler workflows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx zod-aot generate src/ &lt;span class="nt"&gt;-o&lt;/span&gt; dist/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The compiled validators expose the same interface: &lt;code&gt;.parse()&lt;/code&gt; throws on invalid input, &lt;code&gt;.safeParse()&lt;/code&gt; returns a result object, and &lt;code&gt;.is()&lt;/code&gt; acts as a type guard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transform and Refine: Partial Compilation
&lt;/h2&gt;

&lt;p&gt;Here's where it gets interesting. Not everything in a Zod schema can be compiled ahead of time.&lt;/p&gt;

&lt;p&gt;Zod's &lt;code&gt;.transform()&lt;/code&gt;, &lt;code&gt;.refine()&lt;/code&gt;, &lt;code&gt;.superRefine()&lt;/code&gt;, and &lt;code&gt;z.custom()&lt;/code&gt; accept arbitrary JavaScript functions. These are opaque at build time — there's no way to statically analyze what &lt;code&gt;(val) =&amp;gt; val.toUpperCase()&lt;/code&gt; does without executing it.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;zod-aot&lt;/code&gt; doesn't try to compile them. Instead, it uses &lt;strong&gt;partial compilation&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ProfileSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;                          &lt;span class="c1"&gt;// ✅ AOT compiled&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;                         &lt;span class="c1"&gt;// ✅ AOT compiled&lt;/span&gt;
    &lt;span class="na"&gt;age&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;             &lt;span class="c1"&gt;// ✅ AOT compiled&lt;/span&gt;
    &lt;span class="na"&gt;bio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;        &lt;span class="c1"&gt;// ⚠️ Falls back to Zod&lt;/span&gt;
    &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;refine&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;isPrime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;       &lt;span class="c1"&gt;// ⚠️ Falls back to Zod&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this schema, 3 out of 5 fields get full AOT compilation. The remaining 2 delegate to Zod at runtime — only for those specific fields. The compiler decides this automatically at extraction time. No annotations, no configuration, no manual intervention.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;This is the key design decision&lt;/strong&gt;: compile what you can, fall back gracefully for the rest. A schema that's 80% compilable still gets 80% of the performance benefit.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This means you never have to worry about whether your schema is "compatible" with zod-aot. Every Zod v4 schema works. The question is only how much of it gets compiled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmark Results
&lt;/h2&gt;

&lt;p&gt;Theory is nice. Let's see actual numbers.&lt;/p&gt;

&lt;p&gt;I benchmarked &lt;code&gt;zod-aot&lt;/code&gt; compiled validators against Zod v4's native &lt;code&gt;.parse()&lt;/code&gt; across a range of schema complexities. Each benchmark runs 100,000 iterations with warmup, using &lt;code&gt;performance.now()&lt;/code&gt; for timing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Schema&lt;/th&gt;
&lt;th&gt;Zod v4 &lt;code&gt;.parse()&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;zod-aot&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Simple string (&lt;code&gt;z.string().email()&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;1.2M ops/s&lt;/td&gt;
&lt;td&gt;2.1M ops/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~1.8x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium object (valid input)&lt;/td&gt;
&lt;td&gt;480K ops/s&lt;/td&gt;
&lt;td&gt;2.4M ops/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~5x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium object (invalid input)&lt;/td&gt;
&lt;td&gt;210K ops/s&lt;/td&gt;
&lt;td&gt;4.8M ops/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~23x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large nested object (100 items)&lt;/td&gt;
&lt;td&gt;12K ops/s&lt;/td&gt;
&lt;td&gt;780K ops/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~64x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discriminated union (5 variants)&lt;/td&gt;
&lt;td&gt;320K ops/s&lt;/td&gt;
&lt;td&gt;3.2M ops/s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~10x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern is clear: &lt;strong&gt;the larger and more complex the schema, the greater the AOT benefit&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For a simple string validation, the overhead of Zod's runtime traversal is minimal — you're only walking one or two nodes. The speedup is modest.&lt;/p&gt;

&lt;p&gt;But for a large nested object with 100 array items, each containing multiple validated fields, Zod has to traverse hundreds of schema nodes on every parse call. The AOT-compiled version does the same validation with flat, inlined code — no tree to walk. That's where the 64x gap comes from.&lt;/p&gt;

&lt;p&gt;The invalid input case is particularly interesting. The compiled validator hits the first failing check and returns immediately. Zod's runtime has more overhead even for early exits because of the error collection and traversal machinery.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key takeaway&lt;/strong&gt;: If your hot-path schemas are simple primitives, Zod v4 is already fast enough. If you're validating complex nested objects at high throughput, AOT compilation delivers an order-of-magnitude improvement.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Comparison and Trade-offs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How zod-aot compares to alternatives
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;vs Typia&lt;/strong&gt; (~76M ops/s): Typia is faster because it compiles directly from TypeScript types using a compiler plugin. But it requires you to define validation as TypeScript types — not Zod schemas. If your codebase already uses Zod, migrating to Typia means rewriting every schema.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;vs AJV standalone&lt;/strong&gt;: AJV compiles JSON Schema into validation functions — conceptually the same approach as zod-aot. But AJV operates in the JSON Schema ecosystem. If you're using Zod, you'd need to convert schemas to JSON Schema first, losing Zod-specific features.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;vs Zod v4 native&lt;/strong&gt;: zod-aot is complementary, not competitive. It builds on Zod v4 and uses its internals. You keep writing Zod schemas — zod-aot just makes them faster in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;zod-aot's niche&lt;/strong&gt;: zero migration cost for existing Zod users. You keep your schemas, your types, your ecosystem integrations with tRPC/React Hook Form/Next.js. You just add &lt;code&gt;compile()&lt;/code&gt; and a build plugin.&lt;/p&gt;

&lt;h3&gt;
  
  
  Honest trade-offs
&lt;/h3&gt;

&lt;p&gt;Nothing is free. Here's what you should know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build-time extraction&lt;/strong&gt;: zod-aot runs your schema code once at build time to extract the schema structure. If your schemas have side effects at definition time, this could cause issues.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;transform&lt;/code&gt;/&lt;code&gt;refine&lt;/code&gt;-heavy schemas&lt;/strong&gt; get minimal benefit. If most of your validation logic lives in custom functions, AOT compilation can't help much.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bundle size&lt;/strong&gt;: generated validators are standalone JavaScript — no Zod dependency at runtime, but the generated code itself adds to your bundle. For most schemas this is smaller than Zod itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zod v4 only&lt;/strong&gt;: zod-aot depends on Zod v4's internal schema representation (&lt;code&gt;_zod.def&lt;/code&gt;). It does not support Zod v3.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try It Out
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;zod-aot&lt;/code&gt; is open source under the MIT license.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/wakita181009/zod-aot" rel="noopener noreferrer"&gt;https://github.com/wakita181009/zod-aot&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;npm&lt;/strong&gt;: &lt;a href="https://www.npmjs.com/package/zod-aot" rel="noopener noreferrer"&gt;zod-aot&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Broader schema type coverage (recursive types, branded types)&lt;/li&gt;
&lt;li&gt;Incremental build caching for large codebases&lt;/li&gt;
&lt;li&gt;Source map support for debugging compiled validators&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're using Zod in a performance-sensitive context, give it a try. Star the repo if you find it useful, and file issues for anything that doesn't work. PRs are always welcome.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>zod</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
    <item>
      <title>Enforcing Clean Architecture on AI-Generated Kotlin with Custom Detekt Rules</title>
      <dc:creator>Tetsuya Wakita</dc:creator>
      <pubDate>Sun, 22 Feb 2026 20:26:29 +0000</pubDate>
      <link>https://forem.com/wakita181009/an-llm-broke-my-architecture-in-one-generation-i-made-that-a-build-error-1ae0</link>
      <guid>https://forem.com/wakita181009/an-llm-broke-my-architecture-in-one-generation-i-made-that-a-build-error-1ae0</guid>
      <description>&lt;p&gt;&lt;em&gt;How custom detekt rules, specialized AI agents, and specification-driven development keep Clean Architecture intact — even when an LLM writes the code.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The moment an LLM broke the architecture
&lt;/h2&gt;

&lt;p&gt;I asked an AI coding assistant to add a new use case to the application layer. It wrote the code in under a minute. It also added &lt;code&gt;@Service&lt;/code&gt; to the class, imported &lt;code&gt;org.springframework.stereotype.Service&lt;/code&gt;, and threw an &lt;code&gt;IllegalArgumentException&lt;/code&gt; for invalid input.&lt;/p&gt;

&lt;p&gt;Three architecture rules broken in a single generation.&lt;/p&gt;

&lt;p&gt;LLMs are fast. They're also statistically inclined to produce the most common pattern they've seen — and in the Kotlin ecosystem, that's Spring annotations on everything, exceptions for error handling, and no concept of layer boundaries. Your architecture documentation might say "no Spring in application layer." The LLM has seen 100,000 Spring Boot examples that do exactly the opposite.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://dev.to/wakita181009/clean-architecture-in-kotlin-no-exceptions-no-magic-no-compromise-5ha1"&gt;the previous parts of this series&lt;/a&gt;, I built a Clean Architecture in Kotlin with Gradle module boundaries, Arrow-kt &lt;code&gt;Either&lt;/code&gt; error handling, and CQRS — all enforced at compile time. Those guarantees held because humans followed the rules. Now the developer is an LLM. The question becomes: &lt;strong&gt;how do you make the architecture enforce itself on code the LLM generates?&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Custom detekt rules: architecture as static analysis
&lt;/h2&gt;

&lt;p&gt;Gradle modules prevent cross-layer dependencies — &lt;code&gt;domain&lt;/code&gt; can't import from &lt;code&gt;infrastructure&lt;/code&gt; because the dependency doesn't exist. But within the allowed imports, an LLM can still break the architecture in two fundamental ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Importing forbidden libraries&lt;/strong&gt; into pure layers (Spring annotations in domain/application)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throwing exceptions&lt;/strong&gt; instead of returning &lt;code&gt;Either&amp;lt;Error, T&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both are valid Kotlin. Both compile. Both violate the architecture. Gradle can't catch them. So I wrote two custom detekt rules that can.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 1: ForbiddenLayerImportRule — whitelist-based import enforcement
&lt;/h3&gt;

&lt;p&gt;Instead of blacklisting specific imports (which the LLM can always find creative alternatives for), this rule whitelists what's allowed. Anything not on the list is rejected.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ForbiddenLayerImportRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Rule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;commonAllowedPrefixes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"kotlin."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"java."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"arrow.core."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"arrow.fx.coroutines."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"kotlinx.coroutines."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"org.slf4j."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;visitImportDirective&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;importDirective&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;KtImportDirective&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;visitImportDirective&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;importDirective&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;file&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;importDirective&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;containingKtFile&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;virtualFilePath&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/test/"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;

        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;packageName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;packageFqName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;importPath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;importDirective&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;importedFqName&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;asString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;

        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;layer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removePrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"$projectBase."&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;substringBefore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;layerOwnPackages&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s"&gt;"domain"&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"$projectBase.domain."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="s"&gt;"application"&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"$projectBase.domain."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"$projectBase.application."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;  &lt;span class="c1"&gt;// only enforce on domain and application&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;allowed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;commonAllowedPrefixes&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;layerOwnPackages&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;none&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;importPath&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Finding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nc"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;importDirective&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="s"&gt;"Import '$importPath' is not allowed in $layer layer."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The logic is deliberate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Domain layer&lt;/strong&gt;: can only import Kotlin stdlib, Arrow-kt, coroutines, SLF4J, and its own packages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Application layer&lt;/strong&gt;: the same, plus domain layer packages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test files&lt;/strong&gt;: skipped — tests can import whatever they need (MockK, Kotest, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Other layers&lt;/strong&gt;: not enforced — infrastructure and presentation have legitimate framework needs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When an LLM generates &lt;code&gt;import org.springframework.stereotype.Service&lt;/code&gt; in a use case class, detekt fails the build with a clear message: &lt;em&gt;"Import 'org.springframework.stereotype.Service' is not allowed in application layer."&lt;/em&gt; The LLM can read that message and fix its own output.&lt;/p&gt;

&lt;p&gt;Why whitelist instead of blacklist? Because I can't anticipate every library an LLM might try to import. The whitelist is short and complete: Kotlin, Arrow, coroutines, SLF4J, and own-layer packages. Everything else is denied by default.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rule 2: NoThrowOutsidePresentationRule — enforcing Either for all errors
&lt;/h3&gt;

&lt;p&gt;In &lt;a href="https://dev.to/wakita181009/clean-architecture-in-kotlin-no-exceptions-no-magic-no-compromise-5ha1"&gt;Part 1&lt;/a&gt;, every fallible operation returns &lt;code&gt;Either&amp;lt;XxxError, T&amp;gt;&lt;/code&gt;. No exceptions. The return type is the complete error specification.&lt;/p&gt;

&lt;p&gt;LLMs don't naturally write this way. They reach for &lt;code&gt;throw IllegalArgumentException("invalid input")&lt;/code&gt; because that's what most Kotlin code does.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NoThrowOutsidePresentationRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Rule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;forbiddenLayers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"domain"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"application"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"infrastructure"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;visitThrowExpression&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expression&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;KtThrowExpression&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;visitThrowExpression&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expression&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;file&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;expression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;containingKtFile&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;virtualFilePath&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/test/"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;

        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;packageName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;packageFqName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;layer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;packageName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removePrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"$projectBase."&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;substringBefore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;layer&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;forbiddenLayers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Finding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nc"&gt;Entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expression&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="s"&gt;"throw detected in '$packageName'. Use Either&amp;lt;XxxError, T&amp;gt; instead."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three layers are covered: domain, application, and infrastructure. The only layer where &lt;code&gt;throw&lt;/code&gt; is permitted is presentation — because Spring's exception handler infrastructure (&lt;code&gt;ResponseStatusException&lt;/code&gt;, &lt;code&gt;GraphQLException&lt;/code&gt;) requires it.&lt;/p&gt;

&lt;p&gt;Together, these two rules turn architecture guidelines into compiler-level enforcement. The LLM writes code, the Kotlin compiler checks types, and detekt checks architecture. Violations are build errors, not code review comments.&lt;/p&gt;




&lt;h2&gt;
  
  
  From documentation to specification: SDD for LLMs
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/wakita181009/your-tests-are-slow-because-your-architecture-has-no-seams-499o"&gt;Part 2&lt;/a&gt; introduced the idea that the application module is a direct translation of the product spec. Tests validate the implementation matches the spec. That workflow was manual — an engineer reads the requirements, writes the interfaces, then writes the tests.&lt;/p&gt;

&lt;p&gt;For LLM-driven development, the spec needs to be machine-readable. Not a PDF. Not a Confluence page. A structured markdown file in the repository that an agent can parse directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  The spec template
&lt;/h3&gt;

&lt;p&gt;Every feature starts with a spec file in &lt;code&gt;.claude/specs/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Feature Spec: [Feature Name]&lt;/span&gt;

&lt;span class="gu"&gt;## 1. Context&lt;/span&gt;
[Why this feature exists. Business problem.]

&lt;span class="gu"&gt;## 2. Domain Model Changes&lt;/span&gt;
&lt;span class="gu"&gt;### New Value Objects&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`OrderId`&lt;/span&gt; (@JvmInline value class): Long, must be positive
&lt;span class="p"&gt;  -&lt;/span&gt; Invalid: &lt;span class="sb"&gt;`OrderError.InvalidId(value)`&lt;/span&gt; -&amp;gt; "Invalid order ID: $value"

&lt;span class="gu"&gt;### Domain Errors&lt;/span&gt;
sealed interface OrderError : DomainError:
| Variant      | When                  | Message format              |
|--------------|-----------------------|-----------------------------|
| InvalidId    | ID &amp;lt;= 0               | "Invalid order ID: $value"  |
| NotFound     | No record for this ID | "Order not found: $id"      |

&lt;span class="gu"&gt;## 3. Use Cases&lt;/span&gt;
&lt;span class="gu"&gt;### UC-1: Find Order by ID&lt;/span&gt;
Input: id (Long)
Happy path: validate -&amp;gt; find -&amp;gt; return Order
Error cases:
| Error              | Application Error Type           | HTTP |
|--------------------|----------------------------------|------|
| Invalid ID         | OrderFindByIdError.InvalidId     | 400  |
| Not found          | OrderFindByIdError.NotFound      | 404  |

&lt;span class="gu"&gt;## 4. Presentation&lt;/span&gt;
| Method | Path            | Success | Errors    |
|--------|-----------------|---------|-----------|
| GET    | /api/orders/{id}| 200     | 400, 404  |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The spec is structured enough for an LLM to extract: which value objects to create, what validation rules they have, what error types to define, what use cases to implement, and what HTTP status codes to return. Every field maps directly to a Clean Architecture construct.&lt;/p&gt;

&lt;p&gt;This is the key connection from Part 2: &lt;em&gt;the spec-to-code mapping that was implicit in the engineer's head is now explicit in a file.&lt;/em&gt; The LLM doesn't need to interpret requirements — it reads a table and generates the corresponding Kotlin class.&lt;/p&gt;




&lt;h2&gt;
  
  
  Specialized agents: one agent per layer
&lt;/h2&gt;

&lt;p&gt;Here's the central design insight: &lt;strong&gt;each agent should know only the rules of its own layer.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A domain-implementer agent that knows about Spring is an agent that might use Spring. An infrastructure-implementer that knows about domain logic is an agent that might put logic in the wrong place. The same principle that separates layers in the code separates agents in the pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  The agent architecture
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;orchestrator (opus)
  ├── spec-writer (sonnet)      -&amp;gt; writes .claude/specs/[feature].md
  ├── test-designer (sonnet)    -&amp;gt; designs test cases from spec
  ├── tester (sonnet)           -&amp;gt; writes failing tests (RED)
  ├── domain-implementer (sonnet)
  ├── app-implementer (sonnet)
  ├── infra-implementer (sonnet)
  ├── presentation-implementer (sonnet)
  └── reviewer / linter / security-checker (parallel QA)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each agent is a markdown file with explicit constraints. Here's the opening of the domain-implementer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Domain Implementer Agent&lt;/span&gt;

You implement the domain layer: entities, value objects, error types,
and repository interfaces.

&lt;span class="gu"&gt;## Constraints — STRICTLY ENFORCED&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="gs"&gt;**NO**&lt;/span&gt; Spring annotations (@Component, @Repository, @Service)
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**NO**&lt;/span&gt; JooQ, R2DBC, JDBC imports
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**NO**&lt;/span&gt; &lt;span class="sb"&gt;`throw`&lt;/span&gt; statements (use Either / raise() / ensure())
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**ONLY**&lt;/span&gt; allowed: Kotlin stdlib, Arrow-kt, SLF4J
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The constraints mirror the detekt rules — but at the prompt level. The agent is told what it cannot do before it writes a single line. If it still violates, detekt catches it at build time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Skills as domain knowledge
&lt;/h3&gt;

&lt;p&gt;Each agent references skills — structured knowledge about specific patterns:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Skill&lt;/th&gt;
&lt;th&gt;What it contains&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ca-kotlin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Layer rules, module dependencies, what each layer can import&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fp-kotlin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Immutability patterns, Either chaining, sealed error hierarchies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tdd-kotlin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Test patterns per layer, MockK/Kotest conventions, property-based testing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sdd-spec&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Spec template, how to extract domain constructs from requirements&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;arrow-kt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Arrow-kt specific patterns: &lt;code&gt;either { }&lt;/code&gt;, &lt;code&gt;.bind()&lt;/code&gt;, &lt;code&gt;mapLeft&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jooq-ddl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;jOOQ DSL patterns, DDL codegen, reactive Mono/Flux usage&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The domain-implementer loads &lt;code&gt;ca-kotlin&lt;/code&gt; and &lt;code&gt;fp-kotlin&lt;/code&gt;. The infra-implementer loads &lt;code&gt;ca-kotlin&lt;/code&gt; and &lt;code&gt;jooq-ddl&lt;/code&gt;. The tester loads &lt;code&gt;tdd-kotlin&lt;/code&gt;. Each agent gets the knowledge relevant to its layer — no more, no less.&lt;/p&gt;

&lt;h3&gt;
  
  
  Model selection: right model for the right task
&lt;/h3&gt;

&lt;p&gt;Not all tasks need the same model. The orchestrator — which coordinates the entire pipeline, decides when to retry, and synthesizes results — runs on Opus (highest reasoning capability). The implementers and spec-writer run on Sonnet (faster, cheaper, sufficient for structured generation).&lt;/p&gt;

&lt;p&gt;This isn't just cost optimization. It's about matching capability to task complexity. Writing a value object from a spec is pattern-matching. Deciding whether to re-run the implementation phase after a test failure is judgment.&lt;/p&gt;




&lt;h2&gt;
  
  
  The pipeline: from spec to shipped
&lt;/h2&gt;

&lt;p&gt;Here's what a complete implementation cycle looks like using slash commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/spec order-api        -&amp;gt; spec-writer creates .claude/specs/order-api.md
/spec-review order-api -&amp;gt; spec-reviewer validates completeness
/test-design order-api -&amp;gt; test-designer creates test plan from spec
/impl order-api        -&amp;gt; orchestrator runs the full pipeline
/qa                    -&amp;gt; QA pipeline: build + lint + security + review
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;/impl&lt;/code&gt; command is where the interesting engineering happens. It has to respect the dependency graph between layers while maximizing parallelism.&lt;/p&gt;

&lt;h3&gt;
  
  
  The dependency-aware parallelization
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Phase 1 (sequential):  spec read -&amp;gt; domain-implementer -&amp;gt; app-implementer
Phase 2 (parallel):    infra-implementer | presentation-implementer | test-implementer
Phase 3 (parallel):    builder | linter | security-checker
Phase 4 (sequential):  code-reviewer -&amp;gt; documenter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Domain must complete before application — use cases depend on domain entities, value objects, and repository interfaces. Application must complete before infrastructure, presentation, and tests — all three consume the use case interfaces but don't depend on each other.&lt;/p&gt;

&lt;p&gt;This maps directly to the Clean Architecture dependency graph. The implementation order follows the dependency direction: &lt;code&gt;domain -&amp;gt; application -&amp;gt; (infrastructure | presentation | tests)&lt;/code&gt;. The architecture that makes code modular also makes the build pipeline parallelizable.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the orchestrator actually does
&lt;/h3&gt;

&lt;p&gt;The orchestrator is the only agent that doesn't write code. It reads the spec, creates a layer sketch (mapping spec elements to Kotlin classes), spawns agents in the correct order, reads test results, and decides whether to retry.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;### Phase 0 — Analyze&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Read the spec
&lt;span class="p"&gt;-&lt;/span&gt; Extract and output a Layer Sketch:
&lt;span class="p"&gt;  -&lt;/span&gt; Domain entity (fields + types)
&lt;span class="p"&gt;  -&lt;/span&gt; Value objects (backing type + validation)
&lt;span class="p"&gt;  -&lt;/span&gt; Repository interface (method signatures)
&lt;span class="p"&gt;  -&lt;/span&gt; Use cases (interface name + execute signature)
&lt;span class="p"&gt;-&lt;/span&gt; List every file to create before spawning any agent

&lt;span class="gu"&gt;### Phase 1 — Red (tester first, alone)&lt;/span&gt;
Spawn only the tester agent. Wait for completion.

&lt;span class="gu"&gt;### Phase 2 — Green (implementer reads tests)&lt;/span&gt;
Spawn implementer alone. Wait for completion.

&lt;span class="gu"&gt;### Phase 3 — Verify + Review (parallel)&lt;/span&gt;
Spawn reviewer, linter, security-checker simultaneously.
Run: ./gradlew test koverVerify detekt

&lt;span class="gu"&gt;### Phase 4 — Synthesize&lt;/span&gt;
If tests are red, re-spawn implementer with failure output.
Cap iterations at 3.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "red -&amp;gt; green -&amp;gt; verify" loop is the SDD + TDD workflow from &lt;a href="https://dev.to/wakita181009/your-tests-are-slow-because-your-architecture-has-no-seams-499o"&gt;Part 2&lt;/a&gt; — automated. Tests are written before implementation. Implementation is written to pass the tests. The architecture rules are checked after every cycle. The orchestrator doesn't trust the implementer — it verifies.&lt;/p&gt;




&lt;h2&gt;
  
  
  Hooks: the last line of defense
&lt;/h2&gt;

&lt;p&gt;Claude Code supports hooks — shell commands that trigger on specific events. Two hooks close the loop between the LLM and the architecture rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PostToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Write|Edit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cd $CLAUDE_PROJECT_DIR &amp;amp;&amp;amp; ./gradlew ktlintFormat 2&amp;gt;&amp;amp;1 | tail -60"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Stop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cd $CLAUDE_PROJECT_DIR &amp;amp;&amp;amp; ./gradlew detekt 2&amp;gt;&amp;amp;1 | tail -60"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;PostToolUse on Write/Edit&lt;/strong&gt;: Every time the LLM creates or modifies a file, ktlint auto-formats it. No style drift. No formatting discussions. The code matches the project style before the LLM's next turn.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stop&lt;/strong&gt;: When the LLM finishes its work, detekt runs automatically. If the custom rules detect a &lt;code&gt;throw&lt;/code&gt; in the domain layer or a Spring import in the application layer, the violation is surfaced immediately. The LLM sees the failure output and can self-correct.&lt;/p&gt;

&lt;p&gt;This creates a feedback loop: write -&amp;gt; auto-format -&amp;gt; continue -&amp;gt; finish -&amp;gt; architecture check -&amp;gt; fix if violated. The LLM operates within the enforcement boundary in real time.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://dev.to/wakita181009/i-swapped-the-database-client-and-added-graphql-the-domain-never-noticed-24k9"&gt;Part 3&lt;/a&gt;, I proved that swapping the database changed zero files in domain or application. In &lt;a href="https://dev.to/wakita181009/cqrs-into-clean-architecture-the-read-path-got-a-fast-path-and-the-domain-got-smaller-371i"&gt;Part 4&lt;/a&gt;, CQRS actually &lt;em&gt;simplified&lt;/em&gt; the domain by removing read responsibilities that didn't belong there. Hooks guarantee those architectural properties hold not just for those one-time changes — but for every code change an LLM makes, continuously.&lt;/p&gt;




&lt;h2&gt;
  
  
  The enforcement stack
&lt;/h2&gt;

&lt;p&gt;Here's the complete picture of how architecture rules are enforced across the stack:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;th&gt;What it prevents&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gradle modules&lt;/td&gt;
&lt;td&gt;Compile-time dependency graph&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;domain&lt;/code&gt; importing from &lt;code&gt;infrastructure&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ForbiddenLayerImportRule&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;AST-level import whitelist&lt;/td&gt;
&lt;td&gt;Spring/JPA/framework imports in pure layers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NoThrowOutsidePresentationRule&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;AST-level throw detection&lt;/td&gt;
&lt;td&gt;Exceptions in domain/application/infrastructure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kover 80% threshold&lt;/td&gt;
&lt;td&gt;Line coverage gate&lt;/td&gt;
&lt;td&gt;Untested error paths&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent constraints&lt;/td&gt;
&lt;td&gt;Prompt-level rules&lt;/td&gt;
&lt;td&gt;Agent writing code outside its layer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skills&lt;/td&gt;
&lt;td&gt;Domain knowledge injection&lt;/td&gt;
&lt;td&gt;Agent using wrong patterns for its layer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostToolUse hook&lt;/td&gt;
&lt;td&gt;Auto-format on every write&lt;/td&gt;
&lt;td&gt;Style drift&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stop hook&lt;/td&gt;
&lt;td&gt;Detekt on every completion&lt;/td&gt;
&lt;td&gt;Architecture violations in final output&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each layer catches what the layer above might miss. The agent prompt says "no throw." If the LLM ignores it, detekt catches it. If detekt isn't run, Kover might catch the untested exception path. Defense in depth — the same principle that makes Clean Architecture resilient makes the LLM pipeline resilient.&lt;/p&gt;




&lt;h2&gt;
  
  
  The honest tradeoffs
&lt;/h2&gt;

&lt;p&gt;This setup has real costs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Initial investment is high.&lt;/strong&gt; 18 agent definitions, 7 skills, 6 commands, 2 custom detekt rules, and a hook configuration. That's a significant upfront cost before a single feature is implemented through the pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent definitions need maintenance.&lt;/strong&gt; When the project's patterns evolve — say, migrating from jOOQ to Exposed — every agent and skill that references jOOQ needs updating. The knowledge isn't centralized in one place; it's distributed across agent prompts and skill files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model costs add up.&lt;/strong&gt; Running an Opus orchestrator that spawns multiple Sonnet agents per feature isn't cheap. For a solo developer on a side project, the cost-per-feature may not justify the automation. For a team shipping multiple features per sprint, the math changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pipeline is only as good as the spec.&lt;/strong&gt; An ambiguous or incomplete spec produces ambiguous or incomplete code. The automation amplifies whatever you feed it. Garbage spec, garbage implementation — faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  What makes the cost worth it
&lt;/h3&gt;

&lt;p&gt;The payoff is in the second feature, not the first. Once the agents, skills, and rules exist, adding a new feature follows the same path: write a spec, run &lt;code&gt;/impl&lt;/code&gt;, review the output. The architectural guarantees from Parts 1-4 are maintained automatically. No code review needed to catch Spring imports in the domain layer. No manual checks for exception-based error handling. The pipeline enforces what the architecture demands.&lt;/p&gt;

&lt;p&gt;For teams with multiple engineers (including AI agents) contributing to the same codebase, the enforcement stack is the difference between architecture that degrades over time and architecture that holds.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this series built
&lt;/h2&gt;

&lt;p&gt;Five articles. One architecture. Database migration, new protocols, CQRS optimization, LLM-generated code — each article applied a different pressure. The architecture held every time. Not because engineers were careful, but because the constraints were structural.&lt;/p&gt;

&lt;p&gt;Clean Architecture was designed to make software maintainable by humans. It turns out the same constraints — explicit dependencies, typed errors, pure layers — are exactly what LLMs need to write code safely. The architecture didn't change. The developer did.&lt;/p&gt;

&lt;p&gt;The full source is on GitHub: &lt;a href="https://github.com/wakita181009/clean-architecture-kotlin/tree/v4" rel="noopener noreferrer"&gt;https://github.com/wakita181009/clean-architecture-kotlin/tree/v4&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>ai</category>
      <category>cleanarchitecture</category>
      <category>llm</category>
    </item>
    <item>
      <title>CQRS with Clean Architecture in Kotlin: Separating Read and Write Paths for Better Performance</title>
      <dc:creator>Tetsuya Wakita</dc:creator>
      <pubDate>Sun, 22 Feb 2026 19:47:12 +0000</pubDate>
      <link>https://forem.com/wakita181009/adding-cqrs-to-clean-architecture-the-read-path-got-a-fast-path-and-the-domain-got-smaller-4mcp</link>
      <guid>https://forem.com/wakita181009/adding-cqrs-to-clean-architecture-the-read-path-got-a-fast-path-and-the-domain-got-smaller-4mcp</guid>
      <description>&lt;p&gt;&lt;em&gt;How CQRS inside Clean Architecture gave reads a fast path while keeping writes honest&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The read path is doing work it doesn't need to
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://dev.to/wakita181009/i-swapped-the-database-client-and-added-graphql-the-domain-never-noticed-24k9"&gt;Part 3&lt;/a&gt;,&lt;br&gt;
I swapped Spring Data R2DBC for jOOQ and added GraphQL alongside REST. The domain and application layers: zero files&lt;br&gt;
changed.&lt;/p&gt;

&lt;p&gt;But something bothered me about the read path. When a client calls &lt;code&gt;GET /api/github-repos&lt;/code&gt;, here's what happens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The controller calls &lt;code&gt;gitHubRepoListUseCase.execute(pageNumber, pageSize)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The use case validates pagination parameters&lt;/li&gt;
&lt;li&gt;The repository queries the database&lt;/li&gt;
&lt;li&gt;The database rows are mapped into &lt;strong&gt;domain entities&lt;/strong&gt; — constructing &lt;code&gt;GitHubRepoId&lt;/code&gt;, &lt;code&gt;GitHubOwner&lt;/code&gt;, &lt;code&gt;GitHubRepoName&lt;/code&gt;
value objects with full validation&lt;/li&gt;
&lt;li&gt;The domain entities are mapped into REST response DTOs&lt;/li&gt;
&lt;li&gt;The DTOs are serialized to JSON&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 4 and 5 are pure overhead. The client asked for a list of repos. The read path doesn't modify anything, doesn't&lt;br&gt;
enforce business invariants, doesn't trigger side effects. It just needs data — but it's constructing fully validated&lt;br&gt;
domain objects, only to immediately unwrap them into flat DTOs.&lt;/p&gt;

&lt;p&gt;Writes are different. When a client calls &lt;code&gt;POST /api/github-repos&lt;/code&gt;, domain validation matters. The &lt;code&gt;GitHubRepoId&lt;/code&gt; must&lt;br&gt;
be positive. The &lt;code&gt;GitHubOwner&lt;/code&gt; must not be blank. The &lt;code&gt;GitHubRepoName&lt;/code&gt; must not be blank. Those invariants protect the&lt;br&gt;
system's integrity. The domain model earns its keep on the write path.&lt;/p&gt;

&lt;p&gt;This is the core insight behind CQRS: &lt;strong&gt;reads and writes have fundamentally different needs.&lt;/strong&gt; Forcing them through the&lt;br&gt;
same model means one path is always doing unnecessary work.&lt;/p&gt;


&lt;h2&gt;
  
  
  What CQRS changes in Clean Architecture
&lt;/h2&gt;

&lt;p&gt;CQRS — Command Query Responsibility Segregation — separates the read model from the write model. In its simplest form (&lt;br&gt;
no event sourcing, no separate databases), it means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Commands&lt;/strong&gt; (writes) go through the full domain model: validation, invariants, business rules&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queries&lt;/strong&gt; (reads) bypass the domain and return DTOs directly from the database&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The application layer splits into two sub-packages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;application/
├── command/                    # Write path
│   ├── dto/github/
│   │   └── GitHubRepoDto.kt
│   ├── error/github/
│   │   └── GitHubRepoSaveError.kt
│   └── usecase/github/
│       ├── GitHubRepoSaveUseCase.kt
│       └── GitHubRepoSaveUseCaseImpl.kt
│
└── query/                      # Read path
    ├── dto/
    │   ├── PageDto.kt
    │   └── github/
    │       └── GitHubRepoQueryDto.kt
    ├── error/github/
    │   ├── GitHubRepoFindByIdQueryError.kt
    │   └── GitHubRepoListQueryError.kt
    ├── repository/github/
    │   └── GitHubRepoQueryRepository.kt
    └── usecase/github/
        ├── GitHubRepoFindByIdQueryUseCase.kt
        ├── GitHubRepoFindByIdQueryUseCaseImpl.kt
        ├── GitHubRepoListQueryUseCase.kt
        └── GitHubRepoListQueryUseCaseImpl.kt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Separate DTOs, separate errors, separate repository interfaces. The write path and read path don't share&lt;br&gt;
application-layer types. They share domain value objects for input validation — and nothing else.&lt;/p&gt;


&lt;h2&gt;
  
  
  The command side: unchanged domain flow
&lt;/h2&gt;

&lt;p&gt;The write path keeps the full domain-driven approach from Parts 1-3. The save use case validates input by constructing&lt;br&gt;
domain entities:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoSaveUseCaseImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gitHubRepoRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoSaveUseCase&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoDto&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepoSaveError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;either&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;repo&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDomain&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;mapLeft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepoSaveError&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;ValidationFailed&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;gitHubRepoRepository&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mapLeft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepoSaveError&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;SaveFailed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dto.toDomain()&lt;/code&gt; constructs every value object — &lt;code&gt;GitHubRepoId.of(id)&lt;/code&gt;, &lt;code&gt;GitHubOwner.of(owner)&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;GitHubRepoName.of(name)&lt;/code&gt; — each returning &lt;code&gt;Either&lt;/code&gt;. If any validation fails, the chain short-circuits with&lt;br&gt;
&lt;code&gt;ValidationFailed&lt;/code&gt;. If the repository call fails, it short-circuits with &lt;code&gt;SaveFailed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The domain repository interface is now write-only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoRepository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepo&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GitHubError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Parts 1-3, this interface also had &lt;code&gt;findById&lt;/code&gt; and &lt;code&gt;list&lt;/code&gt;. Those methods are gone — reads no longer flow through the&lt;br&gt;
domain. The domain repository does one thing: persist validated domain entities.&lt;/p&gt;


&lt;h2&gt;
  
  
  The query side: DTOs all the way
&lt;/h2&gt;

&lt;p&gt;Here's the read path. No domain entities. No value object construction on the output path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoListQueryUseCaseImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;queryRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoQueryRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoListQueryUseCase&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;pageNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepoListQueryError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PageDto&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepoQueryDto&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;either&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;validPageNumber&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
                &lt;span class="nc"&gt;PageNumber&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageNumber&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mapLeft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepoListQueryError&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;InvalidPageNumber&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;validPageSize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
                &lt;span class="nc"&gt;PageSize&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mapLeft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepoListQueryError&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;InvalidPageSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;limit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;validPageSize&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;offset&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;validPageNumber&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;validPageSize&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;
            &lt;span class="n"&gt;queryRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The return type tells the story: &lt;code&gt;Either&amp;lt;GitHubRepoListQueryError, PageDto&amp;lt;GitHubRepoQueryDto&amp;gt;&amp;gt;&lt;/code&gt;. Not&lt;br&gt;
&lt;code&gt;Page&amp;lt;GitHubRepo&amp;gt;&lt;/code&gt;. The query returns a &lt;code&gt;PageDto&lt;/code&gt; of &lt;code&gt;GitHubRepoQueryDto&lt;/code&gt; — a flat data class with no domain types:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoQueryDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;fullName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;stargazersCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;forksCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;isPrivate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OffsetDateTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;updatedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OffsetDateTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plain types. &lt;code&gt;Long&lt;/code&gt;, &lt;code&gt;String&lt;/code&gt;, &lt;code&gt;Int&lt;/code&gt;, &lt;code&gt;Boolean&lt;/code&gt;. No &lt;code&gt;GitHubRepoId&lt;/code&gt;, no &lt;code&gt;GitHubOwner&lt;/code&gt;, no &lt;code&gt;GitHubRepoName&lt;/code&gt;. The DTO is a&lt;br&gt;
direct projection of the database row — no domain construction overhead.&lt;/p&gt;


&lt;h2&gt;
  
  
  The query repository: why it lives in the application layer
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://dev.to/wakita181009/clean-architecture-in-kotlin-no-exceptions-no-magic-no-compromise-5ha1"&gt;Part 1&lt;/a&gt;, the&lt;br&gt;
repository interface was defined in the domain layer — because the domain needs to express what persistence operations&lt;br&gt;
it requires. That's still true for writes. &lt;code&gt;GitHubRepoRepository.save()&lt;/code&gt; takes a domain entity and returns a domain&lt;br&gt;
entity.&lt;/p&gt;

&lt;p&gt;But the query repository is different:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoQueryRepository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepoFindByIdQueryError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoQueryDto&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

    &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepoListQueryError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;PageDto&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepoQueryDto&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things stand out. First, the error types are the full sealed interfaces (&lt;code&gt;GitHubRepoFindByIdQueryError&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;GitHubRepoListQueryError&lt;/code&gt;), not just &lt;code&gt;FetchFailed&lt;/code&gt;. The infrastructure implementation is responsible for resolving "not&lt;br&gt;
found" — it queries the database and knows whether a row exists. The application layer handles input validation (&lt;br&gt;
&lt;code&gt;InvalidId&lt;/code&gt;). Each layer raises the errors within its scope.&lt;/p&gt;

&lt;p&gt;Second, this interface lives in &lt;code&gt;application/query/repository/&lt;/code&gt;, not &lt;code&gt;domain/repository/&lt;/code&gt;. The reason is structural: it&lt;br&gt;
takes primitives (&lt;code&gt;Long&lt;/code&gt;, &lt;code&gt;Int&lt;/code&gt;) and returns application-layer DTOs (&lt;code&gt;GitHubRepoQueryDto&lt;/code&gt;, &lt;code&gt;PageDto&lt;/code&gt;). It has no domain&lt;br&gt;
types in its signature. Placing it in the domain layer would force the domain to know about &lt;code&gt;GitHubRepoQueryDto&lt;/code&gt; — an&lt;br&gt;
application-layer concept. That breaks the dependency rule.&lt;/p&gt;

&lt;p&gt;The query repository is an application-layer port. Infrastructure implements it. The domain doesn't know it exists.&lt;/p&gt;


&lt;h2&gt;
  
  
  Pragmatic validation: reusing domain value objects
&lt;/h2&gt;

&lt;p&gt;The query side doesn't construct domain entities — but it does reuse domain value objects for &lt;strong&gt;input validation&lt;/strong&gt;. Look&lt;br&gt;
at &lt;code&gt;GitHubRepoFindByIdQueryUseCaseImpl&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoFindByIdQueryUseCaseImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;queryRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoQueryRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoFindByIdQueryUseCase&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepoFindByIdQueryError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoQueryDto&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;either&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;repoId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;mapLeft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepoFindByIdQueryError&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nc"&gt;InvalidId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;queryRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repoId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GitHubRepoId.of(id)&lt;/code&gt; is a domain value object. The query uses it to validate that the ID is positive — the same rule&lt;br&gt;
the write path enforces. But after validation, it extracts &lt;code&gt;.value&lt;/code&gt; and passes the raw &lt;code&gt;Long&lt;/code&gt; to the query repository.&lt;br&gt;
The domain entity &lt;code&gt;GitHubRepo&lt;/code&gt; is never constructed.&lt;/p&gt;

&lt;p&gt;This is pragmatic CQRS. Domain validation rules (positive IDs, page size limits) are universal — they apply to reads and&lt;br&gt;
writes equally. Duplicating that logic would be worse than reusing it. But domain &lt;em&gt;entities&lt;/em&gt; — the full aggregate with&lt;br&gt;
all its fields validated and wrapped — are only needed when writing.&lt;/p&gt;

&lt;p&gt;The same pattern applies to pagination. &lt;code&gt;PageNumber.of()&lt;/code&gt; and &lt;code&gt;PageSize.of()&lt;/code&gt; enforce business constraints (minimum 1,&lt;br&gt;
maximum 100). The query converts them to &lt;code&gt;limit&lt;/code&gt; and &lt;code&gt;offset&lt;/code&gt; — database concepts — and the query repository takes those&lt;br&gt;
primitives directly.&lt;/p&gt;


&lt;h2&gt;
  
  
  Infrastructure: two repositories, one database
&lt;/h2&gt;

&lt;p&gt;Both repositories read from and write to the same &lt;code&gt;github_repo&lt;/code&gt; table. The split is logical, not physical.&lt;/p&gt;

&lt;p&gt;The query repository implementation maps database records directly to DTOs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Repository&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoQueryRepositoryImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;dsl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DSLContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoQueryRepository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepoFindByIdQueryError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoQueryDto&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;either&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;dto&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
                &lt;span class="nc"&gt;Either&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="nc"&gt;Mono&lt;/span&gt;
                            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                                &lt;span class="n"&gt;dsl&lt;/span&gt;
                                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;selectFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GITHUB_REPO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GITHUB_REPO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
                            &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toQueryDto&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;awaitSingleOrNull&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;mapLeft&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoFindByIdQueryError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FetchFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="s"&gt;"Unknown error"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

            &lt;span class="nf"&gt;ensureNotNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoFindByIdQueryError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;it.toQueryDto()&lt;/code&gt; maps a jOOQ &lt;code&gt;GithubRepoRecord&lt;/code&gt; to &lt;code&gt;GitHubRepoQueryDto&lt;/code&gt;. No domain entity involved. The database row&lt;br&gt;
becomes a DTO in a single step. &lt;code&gt;ensureNotNull&lt;/code&gt; from Arrow-kt handles the "not found" case — if the query returns&lt;br&gt;
&lt;code&gt;null&lt;/code&gt;, it raises &lt;code&gt;NotFound&lt;/code&gt; directly. The application layer doesn't need to check for null; the infrastructure resolves&lt;br&gt;
it.&lt;/p&gt;

&lt;p&gt;Compare this with the write repository, which maps to a full domain entity through &lt;code&gt;toDomain()&lt;/code&gt; — constructing value&lt;br&gt;
objects, enforcing invariants. Two implementations of the same table, each optimized for its path.&lt;/p&gt;


&lt;h2&gt;
  
  
  Presentation: same pattern, different types
&lt;/h2&gt;

&lt;p&gt;The controller now injects queries and commands as separate dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@RestController&lt;/span&gt;
&lt;span class="nd"&gt;@RequestMapping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/github-repos"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gitHubRepoListQueryUseCase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoListQueryUseCase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gitHubRepoFindByIdQueryUseCase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoFindByIdQueryUseCase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gitHubRepoSaveUseCase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoSaveUseCase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read endpoints call queries. The write endpoint calls the command use case. The &lt;code&gt;Either.fold&lt;/code&gt; pattern is identical:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/{id}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@PathVariable&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;*&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;gitHubRepoFindByIdQueryUseCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ifLeft&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
            &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoFindByIdQueryError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InvalidId&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
                    &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;badRequest&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoFindByIdQueryError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NotFound&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
                    &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;notFound&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;build&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Nothing&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
                &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoFindByIdQueryError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FetchFailed&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Failed to fetch GitHub repo (id=$id): ${error.message}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;internalServerError&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Internal server error"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;ifRight&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;dto&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
            &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepoResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromQueryDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response DTO now has two factory methods: &lt;code&gt;fromDomain()&lt;/code&gt; for commands (unwrapping value objects) and&lt;br&gt;
&lt;code&gt;fromQueryDto()&lt;/code&gt; for queries (direct field copy):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;companion&lt;/span&gt; &lt;span class="k"&gt;object&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;fromDomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nc"&gt;GitHubRepoResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// unwrap value object&lt;/span&gt;
            &lt;span class="n"&gt;owner&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;fromQueryDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoQueryDto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nc"&gt;GitHubRepoResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;// already a Long&lt;/span&gt;
            &lt;span class="n"&gt;owner&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same split applies to GraphQL. The mapper has &lt;code&gt;GitHubRepoQueryDto.toGraphQL()&lt;/code&gt; for reads and&lt;br&gt;
&lt;code&gt;DomainGitHubRepo.toGraphQL()&lt;/code&gt; for writes. Different source types, same destination type. The presentation layer adapts&lt;br&gt;
both paths to its protocol without knowing which path the data took to get there.&lt;/p&gt;


&lt;h2&gt;
  
  
  Tests: the specification still holds
&lt;/h2&gt;

&lt;p&gt;Query tests mock the query repository. Command tests mock the domain repository. Each side has its own test fixtures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoListQueryUseCaseImplTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;queryRepository&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mockk&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepoQueryRepository&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;queryUseCase&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoListQueryUseCaseImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queryRepository&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`execute&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Right&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="n"&gt;parameters&lt;/span&gt; &lt;span class="n"&gt;are&lt;/span&gt; &lt;span class="nf"&gt;valid`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;page&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PageDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;totalCount&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sampleQueryDto&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
            &lt;span class="nf"&gt;coEvery&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;queryRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Right&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="n"&gt;queryUseCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeRight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`execute&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Left&lt;/span&gt; &lt;span class="nc"&gt;InvalidPageNumber&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="n"&gt;pageNumber&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;queryUseCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeLeft&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoListQueryError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InvalidPageNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PageNumberError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BelowMinimum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`execute&lt;/span&gt; &lt;span class="n"&gt;calculates&lt;/span&gt; &lt;span class="n"&gt;correct&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;page&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PageDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;totalCount&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sampleQueryDto&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
            &lt;span class="nf"&gt;coEvery&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;queryRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Right&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="n"&gt;queryUseCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeRight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the offset test. Page 2 with size 20 should produce &lt;code&gt;offset = 20&lt;/code&gt;. This calculation —&lt;br&gt;
&lt;code&gt;(pageNumber - 1) * pageSize&lt;/code&gt; — happens in the query use case, not the repository. The repository receives &lt;code&gt;limit&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;offset&lt;/code&gt; as primitives. The business logic of pagination lives in the application layer; the database execution lives in&lt;br&gt;
infrastructure.&lt;/p&gt;

&lt;p&gt;The command tests are unchanged&lt;br&gt;
from &lt;a href="https://dev.to/wakita181009/your-tests-are-slow-because-your-architecture-has-no-seams-499o"&gt;Part 2&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoSaveUseCaseImplTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;repository&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mockk&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepoRepository&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;useCase&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoSaveUseCaseImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`execute&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Left&lt;/span&gt; &lt;span class="nc"&gt;ValidationFailed&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="n"&gt;dto&lt;/span&gt; &lt;span class="n"&gt;has&lt;/span&gt; &lt;span class="n"&gt;invalid&lt;/span&gt; &lt;span class="nf"&gt;id`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;dto&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sampleDto&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;useCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeLeft&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoSaveError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ValidationFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GitHubError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InvalidId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0L&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Domain validation on the write path. DTO projection on the read path. Each test verifies the right behavior for its side&lt;br&gt;
of the split.&lt;/p&gt;




&lt;h2&gt;
  
  
  The honest tradeoff
&lt;/h2&gt;

&lt;p&gt;CQRS adds types. Specifically:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Before (unified)&lt;/th&gt;
&lt;th&gt;After (CQRS)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 repository interface&lt;/td&gt;
&lt;td&gt;2 repository interfaces&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 DTO type&lt;/td&gt;
&lt;td&gt;2 DTO types (command + query)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 use cases, 1 error hierarchy per use case&lt;/td&gt;
&lt;td&gt;1 command + 2 queries, separate error hierarchies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 infrastructure implementation&lt;/td&gt;
&lt;td&gt;2 infrastructure implementations&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's roughly double the application-layer surface area for the same feature set. For a project with one entity, this&lt;br&gt;
feels like overkill. For a project with dozens of entities, where read and write patterns diverge significantly —&lt;br&gt;
different fields projected, different joins, different caching strategies — the separation pays for itself.&lt;/p&gt;

&lt;p&gt;The key question is: &lt;strong&gt;will your read and write patterns diverge?&lt;/strong&gt; If yes, CQRS gives you the seam to evolve them&lt;br&gt;
independently. If no — if every read returns exactly the same shape as the domain entity — the unified model from Parts&lt;br&gt;
1-3 is simpler and sufficient.&lt;/p&gt;

&lt;p&gt;In this project, the divergence is modest. But the architecture is now ready for it to grow. Adding a search query that&lt;br&gt;
joins across tables, a dashboard aggregation that returns computed fields, or a denormalized read model for high-traffic&lt;br&gt;
endpoints — none of these will touch the command side or the domain.&lt;/p&gt;

&lt;p&gt;The full source is on GitHub: &lt;a href="https://github.com/wakita181009/clean-architecture-kotlin/tree/v3" rel="noopener noreferrer"&gt;https://github.com/wakita181009/clean-architecture-kotlin/tree/v3&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>cqrs</category>
      <category>cleanarchitecture</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Clean Architecture in Practice: Swapping Database Clients and Adding GraphQL in Kotlin</title>
      <dc:creator>Tetsuya Wakita</dc:creator>
      <pubDate>Sat, 21 Feb 2026 03:46:45 +0000</pubDate>
      <link>https://forem.com/wakita181009/i-swapped-the-database-client-and-added-graphql-the-domain-never-noticed-24k9</link>
      <guid>https://forem.com/wakita181009/i-swapped-the-database-client-and-added-graphql-the-domain-never-noticed-24k9</guid>
      <description>&lt;p&gt;&lt;em&gt;How Clean Architecture delivered on its promise — infrastructure and presentation changed completely, domain and application stayed identical.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The claim every architecture post makes
&lt;/h2&gt;

&lt;p&gt;Every Clean Architecture tutorial promises the same thing: swap your database, change your API protocol, and the business logic stays untouched.&lt;/p&gt;

&lt;p&gt;Most tutorials stop there. They show the diagram, explain the dependency rule, and leave you to wonder whether it actually holds when you try it.&lt;/p&gt;

&lt;p&gt;This article is the proof of concept.&lt;/p&gt;

&lt;p&gt;Starting from the project in &lt;a href="https://dev.to/wakita181009/clean-architecture-in-kotlin-no-exceptions-no-magic-no-compromise-5ha1"&gt;Part 1&lt;/a&gt; — a Spring Boot API backed by Spring Data R2DBC — I made two changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure&lt;/strong&gt;: replaced Spring Data R2DBC with &lt;strong&gt;&lt;a href="https://www.jooq.org/" rel="noopener noreferrer"&gt;jOOQ&lt;/a&gt;&lt;/strong&gt; for type-safe SQL&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Presentation&lt;/strong&gt;: added a &lt;strong&gt;GraphQL API&lt;/strong&gt; using &lt;a href="https://netflix.github.io/dgs/" rel="noopener noreferrer"&gt;Netflix DGS&lt;/a&gt; alongside the existing REST endpoints&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the &lt;code&gt;git diff&lt;/code&gt; for the &lt;code&gt;domain&lt;/code&gt; module: nothing. For the &lt;code&gt;application&lt;/code&gt; module: nothing. Every use case, every domain entity, every value object, every error type — unchanged.&lt;/p&gt;

&lt;p&gt;Let's walk through what actually changed, and why everything else didn't need to.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we're changing and why
&lt;/h2&gt;

&lt;p&gt;The original infrastructure layer used Spring Data R2DBC with a generated entity class and a Spring repository interface. It worked — but the mapping was manual and the queries were limited to what Spring Data's derived query methods could express.&lt;/p&gt;

&lt;p&gt;jOOQ generates a type-safe DSL from the database schema. Instead of &lt;code&gt;r2dbcRepository.findById(id)&lt;/code&gt;, you write &lt;code&gt;dsl.selectFrom(GITHUB_REPO).where(GITHUB_REPO.ID.eq(id))&lt;/code&gt;. The table name and column names are compile-time constants. A typo in a column name is a build error.&lt;/p&gt;

&lt;p&gt;For the API layer, GraphQL offers something REST doesn't: clients specify exactly the fields they need. Adding it shouldn't change anything about how the business logic works — it's just a new way to receive requests and send responses.&lt;/p&gt;




&lt;h2&gt;
  
  
  Infrastructure: replacing Spring Data with jOOQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Configuration
&lt;/h3&gt;

&lt;p&gt;The jOOQ setup lives in &lt;code&gt;infrastructure&lt;/code&gt; — the only module that knows about databases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Configuration&lt;/span&gt;
&lt;span class="nd"&gt;@EnableTransactionManagement&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;JooqConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;cfi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ConnectionFactory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;dsl&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;DSLContext&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nc"&gt;DSL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;using&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;DSL&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;using&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;derive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="nc"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withRenderQuotedNames&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RenderQuotedNames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NEVER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withRenderNameCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RenderNameCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;transactionalOperator&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;TransactionalOperator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nc"&gt;TransactionalOperator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;R2dbcTransactionManager&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfi&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DSLContext&lt;/code&gt; is initialized with the R2DBC &lt;code&gt;ConnectionFactory&lt;/code&gt; — the same connection pool as before. jOOQ handles reactive execution through Reactor's &lt;code&gt;Publisher&lt;/code&gt; API. The &lt;code&gt;Settings&lt;/code&gt; configure lowercase, unquoted identifiers to match the PostgreSQL schema.&lt;/p&gt;

&lt;h3&gt;
  
  
  Code generation from the schema
&lt;/h3&gt;

&lt;p&gt;jOOQ generates Kotlin classes directly from the Flyway migration SQL — the same SQL that was already in the project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;jooq&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;generator&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"org.jooq.codegen.KotlinGenerator"&lt;/span&gt;
            &lt;span class="nf"&gt;database&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"org.jooq.meta.extensions.ddl.DDLDatabase"&lt;/span&gt;
                &lt;span class="nf"&gt;properties&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nf"&gt;property&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"scripts"&lt;/span&gt;
                        &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"src/main/resources/db/migration/*.sql"&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This generates &lt;code&gt;GITHUB_REPO&lt;/code&gt; as a typed table reference and &lt;code&gt;GithubRepoRecord&lt;/code&gt; as a typed row class. No new schema files. No separate ORM mapping configuration. The SQL migration is the single source of truth.&lt;/p&gt;

&lt;h3&gt;
  
  
  Type-safe queries with the same Either wrapping
&lt;/h3&gt;

&lt;p&gt;The repository implementation looks different from before, but the interface it implements — &lt;code&gt;GitHubRepoRepository&lt;/code&gt; — is identical:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Repository&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoRepositoryImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;dsl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DSLContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoRepository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GitHubError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;either&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;record&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
                &lt;span class="nc"&gt;Either&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="nc"&gt;Mono&lt;/span&gt;
                            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                                &lt;span class="n"&gt;dsl&lt;/span&gt;
                                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;selectFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GITHUB_REPO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GITHUB_REPO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
                            &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDomain&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;awaitSingleOrNull&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;mapLeft&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;GitHubError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RepositoryError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Failed to find repo: ${it.message}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;raise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GitHubError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;record&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GITHUB_REPO.ID&lt;/code&gt; is a typed column reference generated from the schema. &lt;code&gt;.eq(id.value)&lt;/code&gt; is type-checked — passing a &lt;code&gt;String&lt;/code&gt; where a &lt;code&gt;Long&lt;/code&gt; is expected won't compile.&lt;/p&gt;

&lt;p&gt;The error wrapping pattern is the same as before: &lt;code&gt;Either.catch { }&lt;/code&gt; captures any thrown exception, &lt;code&gt;mapLeft&lt;/code&gt; converts it to a &lt;code&gt;GitHubError.RepositoryError&lt;/code&gt;, and &lt;code&gt;.bind()&lt;/code&gt; short-circuits the &lt;code&gt;either { }&lt;/code&gt; block on failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Upsert with jOOQ DSL
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;save&lt;/code&gt; operation benefits from jOOQ's expressive SQL DSL for the &lt;code&gt;INSERT ... ON CONFLICT&lt;/code&gt; upsert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepo&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GitHubError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="nc"&gt;Either&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Mono&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;dsl&lt;/span&gt;
                        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertInto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GITHUB_REPO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GITHUB_REPO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GITHUB_REPO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OWNER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                        &lt;span class="c1"&gt;// ... remaining fields ...&lt;/span&gt;
                        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onConflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GITHUB_REPO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;doUpdate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GITHUB_REPO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OWNER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;excluded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GITHUB_REPO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OWNER&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                        &lt;span class="c1"&gt;// ... update fields ...&lt;/span&gt;
                        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;awaitSingle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDomain&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;mapLeft&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;GitHubError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RepositoryError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Failed to save repo: ${it.message}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.onConflict(GITHUB_REPO.ID).doUpdate()&lt;/code&gt; is standard SQL upsert semantics expressed as Kotlin. &lt;code&gt;excluded(GITHUB_REPO.OWNER)&lt;/code&gt; references the value from the &lt;code&gt;VALUES&lt;/code&gt; clause that was excluded by the conflict — the jOOQ equivalent of PostgreSQL's &lt;code&gt;EXCLUDED.owner&lt;/code&gt;. The column references are compile-time constants. Mistype &lt;code&gt;GITHUB_REPO.OWNR&lt;/code&gt; and the build fails.&lt;/p&gt;

&lt;p&gt;The domain &lt;code&gt;GitHubRepo&lt;/code&gt; entity is unchanged. The &lt;code&gt;toDomain()&lt;/code&gt; mapping reads from &lt;code&gt;GithubRepoRecord&lt;/code&gt; instead of the old Spring Data entity — but the output is the same domain object.&lt;/p&gt;




&lt;h2&gt;
  
  
  Presentation: adding GraphQL with Netflix DGS
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Schema-first with DGS
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://netflix.github.io/dgs/" rel="noopener noreferrer"&gt;Netflix DGS&lt;/a&gt; (Domain Graph Service) takes a schema-first approach. The GraphQL schema lives in &lt;code&gt;presentation/src/main/resources/schema/github.graphqls&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;scalar&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="k"&gt;scalar&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;githubRepo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;!):&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;GitHubRepo&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;githubRepos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;GitHubRepoPage&lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Mutation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;saveGitHubRepo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;GitHubRepoInput&lt;/span&gt;&lt;span class="p"&gt;!):&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;GitHubRepo&lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;GitHubRepo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;fullName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;stargazersCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;forksCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;isPrivate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;updatedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="k"&gt;input&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;GitHubRepoInput&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="c"&gt;# ...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ID&lt;/code&gt; type is replaced with &lt;code&gt;Long&lt;/code&gt; — keeping it consistent with the domain's &lt;code&gt;GitHubRepoId(Long)&lt;/code&gt;. &lt;code&gt;DateTime&lt;/code&gt; maps to &lt;code&gt;OffsetDateTime&lt;/code&gt; in Kotlin. Both are custom scalars registered via &lt;code&gt;graphql-java-extended-scalars&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@DgsComponent&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ScalarConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@DgsRuntimeWiring&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;addScalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;RuntimeWiring&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;RuntimeWiring&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;builder&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ExtendedScalars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GraphQLLong&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ExtendedScalars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DGS codegen generates Kotlin data classes from this schema at build time — &lt;code&gt;GitHubRepo&lt;/code&gt;, &lt;code&gt;GitHubRepoPage&lt;/code&gt;, &lt;code&gt;GitHubRepoInput&lt;/code&gt; — parallel to how jOOQ generates classes from the SQL schema. The schema is the source of truth; the generated types are implementation details.&lt;/p&gt;

&lt;h3&gt;
  
  
  The DataFetcher: Either.fold as GraphQL adapter
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;GitHubRepoDataFetcher&lt;/code&gt; is the GraphQL equivalent of the REST controller. It receives requests, calls use cases, and translates &lt;code&gt;Either&lt;/code&gt; results to GraphQL responses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@DgsComponent&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoDataFetcher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gitHubRepoListUseCase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoListUseCase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gitHubRepoFindByIdUseCase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoFindByIdUseCase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gitHubRepoSaveUseCase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoSaveUseCase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@DgsQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"githubRepo"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;githubRepo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nd"&gt;@InputArgument&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepo&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;gitHubRepoFindByIdUseCase&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;ifLeft&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
                    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoFindByIdError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NotFound&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
                        &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoFindByIdError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InvalidId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoFindByIdError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FetchFailed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;GraphQLException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="n"&gt;ifRight&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toGraphQL&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@DgsQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"githubRepos"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;githubRepos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nd"&gt;@InputArgument&lt;/span&gt; &lt;span class="n"&gt;pageNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nd"&gt;@InputArgument&lt;/span&gt; &lt;span class="n"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoPage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;gitHubRepoListUseCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;ifLeft&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;GraphQLException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;ifRight&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toGraphQL&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@DgsMutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"saveGitHubRepo"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;saveGitHubRepo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nd"&gt;@InputArgument&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoInput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepo&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="n"&gt;gitHubRepoSaveUseCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDto&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;fold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;ifLeft&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nc"&gt;GraphQLException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;ifRight&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toGraphQL&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern is identical to the REST controller: call the use case, &lt;code&gt;fold&lt;/code&gt; on the &lt;code&gt;Either&lt;/code&gt;, map &lt;code&gt;Right&lt;/code&gt; to the response type, handle &lt;code&gt;Left&lt;/code&gt; based on the error type. The only difference is the output format — &lt;code&gt;ResponseEntity&lt;/code&gt; becomes a GraphQL type or a &lt;code&gt;GraphQLException&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Because the schema types &lt;code&gt;id&lt;/code&gt; as &lt;code&gt;Long!&lt;/code&gt;, DGS handles the coercion automatically. There's no need to manually parse or validate the ID in the DataFetcher — the type system does it.&lt;/p&gt;

&lt;p&gt;Compare &lt;code&gt;githubRepo&lt;/code&gt; with the REST &lt;code&gt;findById&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;REST: &lt;code&gt;NotFound&lt;/code&gt; → &lt;code&gt;ResponseEntity.notFound().build()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;GraphQL: &lt;code&gt;NotFound&lt;/code&gt; → &lt;code&gt;null&lt;/code&gt; (GraphQL convention for missing nullable fields)&lt;/li&gt;
&lt;li&gt;REST: &lt;code&gt;FetchFailed&lt;/code&gt; → &lt;code&gt;ResponseEntity.badRequest()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;GraphQL: &lt;code&gt;FetchFailed&lt;/code&gt; → &lt;code&gt;throw GraphQLException(error.message)&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Different protocols, different conventions — same use case, same error types, same business logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  The mapper: same pattern as REST DTOs
&lt;/h3&gt;

&lt;p&gt;Type conversion between domain objects and GraphQL types follows the same extension function pattern as the REST DTOs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nc"&gt;DomainGitHubRepo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toGraphQL&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="nc"&gt;GitHubRepo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// Long — matches scalar Long in schema&lt;/span&gt;
        &lt;span class="n"&gt;owner&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;fullName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fullName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DomainGitHubRepo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;.&lt;/span&gt;&lt;span class="nf"&gt;toGraphQL&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="nc"&gt;GitHubRepoPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toGraphQL&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;totalCount&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;totalCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoInput&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDto&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="nc"&gt;GitHubRepoDto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;owner&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GitHubRepo&lt;/code&gt; here is the DGS-generated GraphQL type. &lt;code&gt;DomainGitHubRepo&lt;/code&gt; is the domain entity — aliased to avoid the name collision. The mapper sits entirely within &lt;code&gt;presentation&lt;/code&gt;, converting between what GraphQL needs and what the domain provides.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GitHubRepoInput.toDto()&lt;/code&gt; produces a &lt;code&gt;GitHubRepoDto&lt;/code&gt; — a plain data class in the &lt;code&gt;application&lt;/code&gt; layer. The DTO's &lt;code&gt;toDomain()&lt;/code&gt; method then performs value-object construction with Arrow's &lt;code&gt;either { }&lt;/code&gt; DSL, so validation errors propagate as &lt;code&gt;Left&lt;/code&gt; through the use case rather than escaping as exceptions.&lt;/p&gt;




&lt;h2&gt;
  
  
  What didn't change: the proof
&lt;/h2&gt;

&lt;p&gt;Here's the complete list of files modified across &lt;code&gt;domain&lt;/code&gt; and &lt;code&gt;application&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(none)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every use case interface and implementation: unchanged. Every domain entity and value object: unchanged. Every error type: unchanged. Every application test from &lt;a href="https://dev.to/wakita181009/your-tests-are-slow-because-your-architecture-has-no-seams-499o"&gt;Part 2&lt;/a&gt;: still passing, without modification. Every domain test from Part 2: still passing, without modification.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;GitHubRepoListUseCase&lt;/code&gt; doesn't know whether its results go to a REST response or a GraphQL query. &lt;code&gt;GitHubRepoFindByIdUseCaseImpl&lt;/code&gt; doesn't know whether the repository behind it uses Spring Data or jOOQ. &lt;code&gt;PageSize.of(101)&lt;/code&gt; returns &lt;code&gt;Left(PageSizeError.AboveMaximum(101))&lt;/code&gt; regardless of what database is running.&lt;/p&gt;

&lt;p&gt;This is what the dependency rule buys you. Infrastructure and presentation know about the domain. The domain knows about neither.&lt;/p&gt;




&lt;h2&gt;
  
  
  The pattern that made this possible
&lt;/h2&gt;

&lt;p&gt;Three design choices made both changes straightforward:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The repository interface is the only contract.&lt;/strong&gt; Infrastructure implements &lt;code&gt;GitHubRepoRepository&lt;/code&gt;. Whether the implementation uses jOOQ, Spring Data, or a flat file doesn't matter — as long as it returns the right &lt;code&gt;Either&amp;lt;GitHubError, T&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Presentation layers are pure adapters.&lt;/strong&gt; Both the REST controller and the GraphQL DataFetcher do exactly the same thing: receive a request, call a use case, translate the &lt;code&gt;Either&lt;/code&gt; result to the protocol's response format. Adding a new protocol means adding a new adapter — not changing anything that exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;Either.fold&lt;/code&gt; is protocol-neutral.&lt;/strong&gt; The use case returns &lt;code&gt;Either&amp;lt;AppError, DomainResult&amp;gt;&lt;/code&gt;. What you do with &lt;code&gt;Left&lt;/code&gt; and &lt;code&gt;Right&lt;/code&gt; is entirely up to the presentation layer. REST maps them to HTTP status codes. GraphQL maps them to nullable fields and exceptions. The use case doesn't need to know which.&lt;/p&gt;

&lt;p&gt;The architecture didn't just survive the change. It made the change straightforward.&lt;/p&gt;

&lt;p&gt;The full source is on GitHub: &lt;a href="https://github.com/wakita181009/clean-architecture-kotlin/tree/v2" rel="noopener noreferrer"&gt;https://github.com/wakita181009/clean-architecture-kotlin/tree/v2&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>cleanarchitecture</category>
      <category>graphql</category>
      <category>springboot</category>
    </item>
    <item>
      <title>Why Your Kotlin Tests Are Slow: How Clean Architecture Enables Sub-Second Unit Testing</title>
      <dc:creator>Tetsuya Wakita</dc:creator>
      <pubDate>Sat, 21 Feb 2026 02:57:17 +0000</pubDate>
      <link>https://forem.com/wakita181009/your-tests-are-slow-because-your-architecture-has-no-seams-499o</link>
      <guid>https://forem.com/wakita181009/your-tests-are-slow-because-your-architecture-has-no-seams-499o</guid>
      <description>&lt;p&gt;&lt;em&gt;How Clean Architecture makes domain and application tests run in milliseconds — with no database, no Spring, and no excuses.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The test that shouldn't need a database
&lt;/h2&gt;

&lt;p&gt;Here's a test I've seen too many times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@SpringBootTest&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PageSizeValidationTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Autowired&lt;/span&gt; &lt;span class="k"&gt;lateinit&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;repoService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoService&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`should&lt;/span&gt; &lt;span class="n"&gt;reject&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;assertThrows&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;IllegalArgumentException&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;repoService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageSize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;101&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This test checks one thing: whether &lt;code&gt;101&lt;/code&gt; is rejected as a page size. To do it, Spring boots up, all beans are wired, the database connection pool is initialized. Eight seconds of startup for a boundary check that has nothing to do with any of it.&lt;/p&gt;

&lt;p&gt;The problem isn't the test. It's that the business logic is baked into a &lt;code&gt;@Service&lt;/code&gt; that can't exist without the container.&lt;/p&gt;

&lt;p&gt;Clean Architecture solves this with a single structural rule: &lt;strong&gt;domain and application layers have zero external dependencies.&lt;/strong&gt; No Spring. No database. No infrastructure of any kind.&lt;/p&gt;

&lt;p&gt;That rule has a direct consequence for testing: &lt;strong&gt;the entire business logic of the system — every rule, every validation, every failure case — can be tested with pure Kotlin.&lt;/strong&gt; No framework startup. No database connection. Just code and assertions.&lt;/p&gt;




&lt;h2&gt;
  
  
  Domain + application = the specification
&lt;/h2&gt;

&lt;p&gt;The key idea from &lt;a href="https://dev.to/wakita181009/clean-architecture-in-kotlin-no-exceptions-no-magic-no-compromise-5ha1"&gt;Part 1&lt;/a&gt;: &lt;strong&gt;the &lt;code&gt;application&lt;/code&gt; module is a direct translation of the product spec into Kotlin.&lt;/strong&gt; Read the use case interfaces and you're reading the requirements. Read the domain errors and you're reading the failure scenarios the spec describes.&lt;/p&gt;

&lt;p&gt;Tests make this claim verifiable. Because domain and application have no external dependencies, every business rule is a plain Kotlin test — write it, run it. Two workflows follow naturally:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Specification-driven development&lt;/strong&gt;: Define use case interfaces and domain errors from the requirements document. Tests validate the implementation matches the spec.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test-driven development&lt;/strong&gt;: Write the use case test first — against the interface — before implementing anything. Infrastructure comes last.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 1: Domain tests — pure Kotlin, zero dependencies
&lt;/h2&gt;

&lt;p&gt;Domain tests have no mocks, no Spring, no database. The only imports are Kotest, kotest-property, and the Arrow assertions extension. Some tests use &lt;code&gt;runTest&lt;/code&gt; — not because they touch coroutines, but because &lt;code&gt;checkAll&lt;/code&gt; is a suspend function.&lt;/p&gt;

&lt;p&gt;There's a natural fit between &lt;code&gt;Either&lt;/code&gt; and property-based testing. When every code path must return &lt;code&gt;Either&amp;lt;E, A&amp;gt;&lt;/code&gt;, the test question shifts from "does it handle the edge case?" to "does the invariant hold for the entire input space?" Property-based testing is the direct answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Property-based testing for value object invariants
&lt;/h3&gt;

&lt;p&gt;The most natural test for a value object isn't "does it reject 101?" — it's "does it reject &lt;em&gt;every&lt;/em&gt; value above 100?" Example-based tests can only sample a few points. Property-based tests state the invariant and verify it holds for hundreds of generated inputs.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PageSize&lt;/code&gt; enforces a business rule: values must be between 1 and 100. Instead of picking specific values, the tests express what must be true for &lt;em&gt;any&lt;/em&gt; value in each region:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PageSizeTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`of&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Right&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="n"&gt;correct&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;valid&lt;/span&gt; &lt;span class="nf"&gt;range`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;checkAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Arb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PageSize&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MIN_VALUE&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="nc"&gt;PageSize&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MAX_VALUE&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
                &lt;span class="nc"&gt;PageSize&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeRight&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`of&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Left&lt;/span&gt; &lt;span class="nc"&gt;BelowMinimum&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="n"&gt;below&lt;/span&gt; &lt;span class="nf"&gt;minimum`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;checkAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Arb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PageSize&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MIN_VALUE&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
                &lt;span class="nc"&gt;PageSize&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeLeft&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="nc"&gt;PageSizeError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BelowMinimum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`of&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Left&lt;/span&gt; &lt;span class="nc"&gt;AboveMaximum&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="n"&gt;above&lt;/span&gt; &lt;span class="nf"&gt;maximum`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;checkAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Arb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PageSize&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MAX_VALUE&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
                &lt;span class="nc"&gt;PageSize&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeLeft&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="nc"&gt;PageSizeError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AboveMaximum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Arb.int(range)&lt;/code&gt; is an &lt;em&gt;arbitrary&lt;/em&gt; — a generator that produces random integers in the specified range. &lt;code&gt;checkAll&lt;/code&gt; runs 1000 iterations by default (configurable), sampling across the entire space. The first test doesn't just check &lt;code&gt;1&lt;/code&gt; and &lt;code&gt;100&lt;/code&gt; — it checks that &lt;code&gt;of(n).value == n&lt;/code&gt; holds for &lt;em&gt;any&lt;/em&gt; valid integer.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;shouldBeRight()&lt;/code&gt; and &lt;code&gt;shouldBeLeft()&lt;/code&gt; from &lt;a href="https://kotest.io/docs/assertions/arrow.html" rel="noopener noreferrer"&gt;kotest-assertions-arrow&lt;/a&gt; unwrap the &lt;code&gt;Either&lt;/code&gt; and assert its branch in a single call.&lt;/p&gt;

&lt;p&gt;No &lt;code&gt;@SpringBootTest&lt;/code&gt;. No &lt;code&gt;@ExtendWith&lt;/code&gt;. No application context. Every test here runs in under 10ms total.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing error messages as contracts
&lt;/h3&gt;

&lt;p&gt;Error messages that flow to API clients are part of the interface. These are deterministic — no property generation needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`BelowMinimum&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="n"&gt;contains&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;invalid&lt;/span&gt; &lt;span class="nf"&gt;value`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PageSizeError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BelowMinimum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="s"&gt;"Page size must be at least ${PageSize.MIN_VALUE}, but was 0"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`AboveMaximum&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="n"&gt;contains&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;invalid&lt;/span&gt; &lt;span class="nf"&gt;value`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PageSizeError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AboveMaximum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="s"&gt;"Page size must be at most ${PageSize.MAX_VALUE}, but was 200"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If someone changes the format, the test fails. If the minimum value constant changes, the message updates automatically. The test pins the contract.&lt;/p&gt;

&lt;h3&gt;
  
  
  Property-based testing for string value objects
&lt;/h3&gt;

&lt;p&gt;String-valued objects like &lt;code&gt;GitHubOwner&lt;/code&gt; have a harder invariant to exhaustively sample: "any non-blank string is valid, any blank string is not." Property tests handle this without hand-picking examples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GitHubOwnerTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`of&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Right&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="n"&gt;correct&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt; &lt;span class="n"&gt;non-blank&lt;/span&gt; &lt;span class="nf"&gt;string`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;checkAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Arb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNotBlank&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
                &lt;span class="nc"&gt;GitHubOwner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeRight&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`of&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Left&lt;/span&gt; &lt;span class="nc"&gt;InvalidOwner&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt; &lt;span class="n"&gt;blank&lt;/span&gt; &lt;span class="nf"&gt;string`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;checkAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Arb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;" "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"   "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"\t"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"\n"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"\r\n"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"\u00A0"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
                &lt;span class="nc"&gt;GitHubOwner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeLeft&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shouldBeInstanceOf&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GitHubError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InvalidOwner&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The invalid-input test uses &lt;code&gt;Arb.of(...)&lt;/code&gt; with an explicit set covering regular spaces, tabs, newlines, and Unicode non-breaking space (&lt;code&gt;\u00A0&lt;/code&gt;). A blank-string check that only tests &lt;code&gt;""&lt;/code&gt; and &lt;code&gt;" "&lt;/code&gt; will miss the unicode edge case. The set makes that coverage explicit and exhaustive for the failure class.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing numeric value objects
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;GitHubRepoId.of(Long)&lt;/code&gt; validates the domain invariant: IDs must be positive. The same property pattern applies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoIdTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`of&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Right&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="n"&gt;correct&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt; &lt;span class="n"&gt;positive&lt;/span&gt; &lt;span class="nf"&gt;id`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;checkAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Arb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;long&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1L&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
                &lt;span class="nc"&gt;GitHubRepoId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeRight&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`of&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Left&lt;/span&gt; &lt;span class="nc"&gt;InvalidId&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt; &lt;span class="n"&gt;non-positive&lt;/span&gt; &lt;span class="nf"&gt;id`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;checkAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Arb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;long&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0L&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
                &lt;span class="nc"&gt;GitHubRepoId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeLeft&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shouldBeInstanceOf&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GitHubError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InvalidId&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`InvalidId&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="n"&gt;contains&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;invalid&lt;/span&gt; &lt;span class="nf"&gt;value`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GitHubError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InvalidId&lt;/span&gt;&lt;span class="p"&gt;(-&lt;/span&gt;&lt;span class="mi"&gt;1L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="s"&gt;"Invalid GitHub repo ID: -1 (must be positive)"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`NotFound&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="n"&gt;contains&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="nf"&gt;id`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GitHubError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepoId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42L&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="s"&gt;"GitHub repo not found: 42"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Arb.long(min = 1L)&lt;/code&gt; generates random positive longs across the full range — not just &lt;code&gt;1L&lt;/code&gt; and &lt;code&gt;9999999L&lt;/code&gt;. If the validation logic had an off-by-one at &lt;code&gt;Long.MAX_VALUE&lt;/code&gt;, a hand-picked example would never catch it. A property test would.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 2: Application tests — the specification in executable form
&lt;/h2&gt;

&lt;p&gt;Domain tests cover what values are legal. Application tests cover what the system &lt;em&gt;does&lt;/em&gt; with legal and illegal values — and what callers see when it fails.&lt;/p&gt;

&lt;p&gt;Use case tests are where specification-driven development becomes concrete. These tests describe what each operation does, in terms of the domain — with no database, no Spring, and no HTTP. Unlike domain tests, application tests use specific values: they're verifying error &lt;em&gt;mapping&lt;/em&gt;, not input ranges.&lt;/p&gt;

&lt;h3&gt;
  
  
  Constructing use cases directly
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoListUseCaseImplTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;repository&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mockk&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepoRepository&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;useCase&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoListUseCaseImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GitHubRepoListUseCaseImpl&lt;/code&gt; is a plain Kotlin class. Construct it with a mocked repository interface and it's ready to test. No Spring context, no bean factory, no &lt;code&gt;@Autowired&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is only possible because &lt;code&gt;GitHubRepoListUseCaseImpl&lt;/code&gt; has no &lt;code&gt;@Service&lt;/code&gt; annotation and no framework imports. It's a class — you instantiate classes with constructors.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing the happy path
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`execute&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Right&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="n"&gt;parameters&lt;/span&gt; &lt;span class="n"&gt;are&lt;/span&gt; &lt;span class="nf"&gt;valid`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;page&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;totalCount&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sampleRepo&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
    &lt;span class="nf"&gt;coEvery&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Right&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;useCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeRight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;runTest&lt;/code&gt; from &lt;code&gt;kotlinx-coroutines-test&lt;/code&gt; runs suspend functions synchronously — no thread management needed. &lt;code&gt;coEvery&lt;/code&gt; stubs the &lt;code&gt;suspend fun list(...)&lt;/code&gt; call with a predefined &lt;code&gt;Either.Right&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing validation errors — the repository is never called
&lt;/h3&gt;

&lt;p&gt;Input validation fires inside the &lt;code&gt;either { }&lt;/code&gt; block before any repository call. Tests for invalid input don't configure the mock at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`execute&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Left&lt;/span&gt; &lt;span class="nc"&gt;InvalidPageNumber&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="n"&gt;pageNumber&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;useCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeLeft&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoListError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InvalidPageNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PageNumberError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BelowMinimum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`execute&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Left&lt;/span&gt; &lt;span class="nc"&gt;InvalidPageSize&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="n"&gt;pageSize&lt;/span&gt; &lt;span class="n"&gt;exceeds&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="err"&gt;`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;useCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;101&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeLeft&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoListError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InvalidPageSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PageSizeError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AboveMaximum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;101&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;either { } + .bind()&lt;/code&gt; chain short-circuits at the first &lt;code&gt;Left&lt;/code&gt;. The repository is never invoked. These tests confirm that invalid inputs are rejected at the use case boundary — before anything hits the database.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing error mapping
&lt;/h3&gt;

&lt;p&gt;Each use case maps domain errors to application-level errors. The mapping is part of the spec — it determines what the caller sees when something goes wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`execute&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Left&lt;/span&gt; &lt;span class="nc"&gt;FetchFailed&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nf"&gt;error`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;domainError&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GitHubError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RepositoryError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"DB error"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;coEvery&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domainError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;useCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeLeft&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoListError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FetchFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domainError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;code&gt;FindById&lt;/code&gt;, the mapping is conditional — the same domain layer produces different application errors depending on the failure type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`execute&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Left&lt;/span&gt; &lt;span class="nc"&gt;InvalidId&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;positive`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;useCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0L&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeLeft&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoFindByIdError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InvalidId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GitHubError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InvalidId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0L&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`execute&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Left&lt;/span&gt; &lt;span class="nc"&gt;NotFound&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;found`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;domainError&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GitHubError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepoId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1L&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="nf"&gt;coEvery&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domainError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;useCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1L&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeLeft&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoFindByIdError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NotFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domainError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`execute&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Left&lt;/span&gt; &lt;span class="nc"&gt;FetchFailed&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="nc"&gt;RepositoryError&lt;/span&gt; &lt;span class="nf"&gt;occurs`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;domainError&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GitHubError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RepositoryError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"DB connection failed"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;coEvery&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domainError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;useCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1L&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeLeft&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoFindByIdError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;FetchFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domainError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GitHubRepoFindByIdError.InvalidId&lt;/code&gt; fires before the repository is ever called — ID validation is a use case responsibility, not infrastructure's. For valid IDs: &lt;code&gt;GitHubError.NotFound&lt;/code&gt; → &lt;code&gt;GitHubRepoFindByIdError.NotFound&lt;/code&gt;; &lt;code&gt;GitHubError.RepositoryError&lt;/code&gt; → &lt;code&gt;GitHubRepoFindByIdError.FetchFailed&lt;/code&gt;. Three domain outcomes. Three tests. The mapping is explicit, typed, and verified.&lt;/p&gt;




&lt;h2&gt;
  
  
  The development workflow this enables
&lt;/h2&gt;

&lt;p&gt;Here's the concrete workflow that becomes available when domain and application have no external dependencies:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1&lt;/strong&gt;: The product spec says "users can list repositories, with pagination between 1 and 100 items per page."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2&lt;/strong&gt;: Write the use case interface and domain errors directly from the spec — before any implementation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoListUseCase&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Either&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepoListError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GitHubRepo&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoListError&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ApplicationError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;InvalidPageNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PageNumberError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoListError&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;InvalidPageSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PageSizeError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoListError&lt;/span&gt;
    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;FetchFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;cause&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GitHubRepoListError&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3&lt;/strong&gt;: Write the tests. Value objects use property-based tests to verify invariants across the full input space. Use cases use specific values to verify that error types and mappings match the spec:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Spec: valid pagination returns a page of results&lt;/span&gt;
&lt;span class="n"&gt;useCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeRight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Spec: page number must be at least 1&lt;/span&gt;
&lt;span class="n"&gt;useCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeLeft&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// GitHubRepoListError.InvalidPageNumber&lt;/span&gt;

&lt;span class="c1"&gt;// Spec: page size must not exceed 100&lt;/span&gt;
&lt;span class="n"&gt;useCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;101&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;shouldBeLeft&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// GitHubRepoListError.InvalidPageSize&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 4&lt;/strong&gt;: Implement the use case to make the tests pass. The repository is a mock — no database needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5&lt;/strong&gt;: Implement the infrastructure. Wire it together. Run the integration tests.&lt;/p&gt;

&lt;p&gt;The business logic is validated before a single line of infrastructure code is written. The tests are the spec. The spec is the tests.&lt;/p&gt;




&lt;h2&gt;
  
  
  What comes after: infrastructure tests
&lt;/h2&gt;

&lt;p&gt;The one layer not covered here is infrastructure — actual database queries require Testcontainers or a real PostgreSQL instance. Those tests are slower and heavier by nature.&lt;/p&gt;

&lt;p&gt;But they're also contained. Domain and application tests never touch the database. The infrastructure layer runs in isolation against a real database. Each layer has the right test strategy for its responsibilities.&lt;/p&gt;

&lt;p&gt;The real value isn't just speed. It's that the barrier to writing a test is zero. There's no decision to make about whether a test is "worth spinning up the container for." If it's a business rule, it has a test. If it has a test, it runs in milliseconds. There's no tradeoff.&lt;/p&gt;

&lt;p&gt;The full source is on GitHub: &lt;a href="https://github.com/wakita181009/clean-architecture-kotlin/tree/v1" rel="noopener noreferrer"&gt;https://github.com/wakita181009/clean-architecture-kotlin/tree/v1&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>testing</category>
      <category>cleanarchitecture</category>
      <category>kotest</category>
    </item>
  </channel>
</rss>
