Kotlin for a CLI Backend: A Personal Choice, Not a Manifesto
Series: Building a Side Project That Runs Itself
- The Origin Story
- The Static Site Bet
- Kotlin for a CLI Backend (this post)
- Multi-Cloud Without the Drama
- The Pipeline That Runs While I Game
- Three Months In: Retrospective
I want to get something out of the way early: using Kotlin for a data processing CLI is not the obvious choice. Python has better SEC parsing libraries, faster prototyping, and a larger ecosystem for this kind of work. If you’re starting a similar project from scratch, Python is probably the sensible default.
I used Kotlin anyway. This post is about why — and I’ll try to be honest about where that decision cost me something.
What the CLI Actually Does
Before talking about the technology, it helps to understand what this thing does every day.
The Kotlin CLI is a Gradle multi-module project with several subcommands, orchestrated via the Clikt framework. On a typical run, it:
- Crawls the EDGAR full-text search index to find recently filed 8-Ks, 10-Ks, S-1s, and other relevant filing types
- Downloads the actual filing documents — not just the index entry, but the underlying HTML and text files
- Calls Gemini with the filing content and a prompt template, receives a structured JSON summary back
- Parses Form 4 filings — insider buy/sell transactions, officer names, share counts, transaction dates
- Pulls RSS feeds from EDGAR and financial news sources for the news digest feature
- Writes all output as structured JSON to S3, where the Astro build can read it at build time
Each of those is a separate subcommand that can be run independently or chained together. The Clikt framework made building that interface clean — you end up with something that reads like a proper CLI tool rather than a pile of main() functions with argument parsing glued on.
Why Kotlin
The honest answer is: because that’s the language I live in at work. I write JVM code every day. I know how to structure a Kotlin project, how to handle coroutines, how to write clean error handling. When something breaks at 11pm, I want to debug in a language I’m fluent in — not one where I’m also fighting the syntax.
There’s also a practical consideration: Google’s Gemini SDKs are first-class in the JVM ecosystem. The Java/Kotlin SDK is well-maintained, the authentication integrations work smoothly with service accounts and OIDC tokens, and the multimodal capabilities (which I use for news digest image generation) are fully supported.
If I were using Python, I’d have access to the sec-edgar-downloader library, which is genuinely excellent and would have saved me some crawling work. That’s a real trade-off I made. The Kotlin EDGAR parsing code is hand-rolled, which means I had to figure out the index file format myself. It wasn’t hard, but it was work I could have skipped.
Clikt for Subcommand Structure
If you’re writing a Kotlin CLI and you’re not using Clikt, take a look at it. It uses Kotlin’s property delegation to declare options and arguments in a way that feels natural:
class CrawlFilings : CliktCommand(name = "crawl") {
private val filingType by option("--type", help = "Filing type (8-K, 10-K, S-1)")
.default("8-K")
private val days by option("--days", help = "How many days back to crawl")
.int()
.default(1)
override fun run() {
// crawl logic here
}
}
The real value is that it keeps subcommands isolated. Each command is its own class. You can test them independently. Adding a new subcommand doesn’t require touching any existing command. For a project that grows incrementally — which every side project does — that separation matters.
I’ve written about the Gradle multi-module setup separately in my Kotlin DSL post, so I won’t repeat it here. The short version: each functional area (EDGAR crawling, Gemini integration, S3 I/O, Form 4 parsing) lives in its own Gradle module. Clikt wires them together at the CLI layer.
Coroutines for Parallel Processing
One place where Kotlin genuinely shines for this use case: coroutines make parallel I/O nearly effortless.
Processing a day’s worth of 8-K filings means downloading and summarizing dozens to hundreds of documents. Doing that sequentially would take forever. With coroutines, I can fan out the work:
val summaries = filings
.map { filing ->
async(Dispatchers.IO) {
summarizeFiling(filing)
}
}
.awaitAll()
That’s the rough shape of it — launch a coroutine per filing, collect results when they’re all done. In practice there’s rate limiting against the Gemini API, so I add a semaphore to cap concurrency. But the structure stays clean, and the performance difference versus sequential processing is significant.
The JVM Startup Tax
Here’s the honest downside: JVM startup time is real. When you run the CLI, you wait a second or two before anything actually happens. For an interactive tool that you run constantly, that’s annoying. For a batch job that runs once a day as a GitHub Actions step, it’s completely irrelevant.
If this were a Lambda function invoked per request, the cold start would be a problem. As a scheduled batch job, I genuinely don’t care.
GraalVM native image would address the startup time, but setting it up for a project with transitive dependencies adds complexity I wasn’t willing to pay for a side project. Maybe someday.
The Right Tool Question
There’s a recurring debate in tech about “right tool for the job” that I find exhausting. In practice, for solo side projects, the right tool is almost always “the one you can actually maintain.”
I can debug Kotlin at 11pm. I understand the type system, the coroutine model, the exception semantics. When the EDGAR index format changes and the parser breaks, I can fix it quickly. That maintainability is worth more to me than whatever productivity gains I’d get from Python’s ecosystem.
If I were starting this project fresh and had no JVM background, I’d probably reach for Python. But I’m not starting fresh. I’m building something I have to live with.
When I Do Use Python (Via Docker)
That said, I’m not dogmatic about staying in one language ecosystem. When Python genuinely has the better tool, I use it — just not by rewriting my entire codebase.
Example: I needed to convert HTML and PDF files from SEC filings into clean Markdown for processing. Python has microsoft/markitdown, which is excellent at this. Kotlin doesn’t have an equivalent library that’s anywhere near as good.
So I built a minimal Python service that depends on markitdown, exposes a single HTTP endpoint (POST /convert), and returns the converted Markdown. I containerized it with Docker, pushed the image to a registry, and deployed it wherever Docker runs — sometimes locally, sometimes on a cloud instance.
My Kotlin code calls that HTTP endpoint and gets back Markdown. Done.
This is the kind of cross-language structure that makes sense to me:
- Use the best tool for each specific problem
- Keep the integration surface small and well-defined (HTTP in this case)
- Don’t let language boundaries force you into suboptimal solutions
For Claude Code or GitHub Copilot, Kotlin vs. Python vs. Rust makes almost no difference — AI can generate competent code in any of them. But for me, as a human maintaining this thing at midnight when something breaks, language choice matters a lot. I want to write, review, and debug in languages I’m fluent in.
Code I Can Still Understand
Here’s something I think about often: AI-generated code is getting better and better. More people are letting AI write entire features. That’s fine for prototyping or for code you’ll throw away, but for a side project I plan to run for years, I need to be able to open any file and understand what it does without relying on AI to explain it back to me.
I’m not building Stockadora to flip it for money next quarter. I’m building it because the process of writing, maintaining, and evolving a real codebase is the part I actually enjoy. I want the code to be simple enough that I can hold the mental model in my head. I want to be able to fix bugs without needing to re-generate entire modules from scratch.
That constraint — “keep it simple enough for one person to maintain” — has shaped every technical decision in this project. It’s why I didn’t reach for a complex microservices architecture. It’s why the Python service is a single Flask app with one route instead of a framework-heavy API. It’s why the Kotlin CLI is structured as isolated Gradle modules that can be understood independently.
AI can write the initial implementation. I can review it, tweak it, and deploy it. But six months later, when I’m debugging a weird edge case at 11pm, I need to be able to read the code and reason about it without external help. Simple code — boring, straightforward, well-structured code — is what makes that possible.
The best tool for the job, for me, is the one I can still maintain when the initial excitement has worn off and I just need the thing to keep working.
Next up: the multi-cloud setup — why I use AWS and GCP for different things, and how Terraform keeps it from becoming chaos.