DEV Community

Cover image for Architecture-first vs problem-first: what five months of over-engineering looks like
Luca
Luca

Posted on

Architecture-first vs problem-first: what five months of over-engineering looks like

Why build something? And what if nobody ends up using it?

There are good answers to the first one. You build because you need a thing that doesn't exist yet. You build to see if you can, the technical challenge, the "is this even possible?" You build to impress someone, or just because you think it'll make people's day a little less annoying.

All of those are real reasons, and at different points, I told myself most of them.

Then, a few days ago, late in the day, at the end of a coding session, five months into the project, I asked myself those two questions back-to-back. And for the first time, I couldn't answer the second one.

Zeri worked. Every feature did what it was supposed to do. Both processes handshake cleanly, a variable set in one context showing up in another a second later, the TUI rendering exactly as I'd pictured it. And I sat there and couldn't come up with one honest sentence explaining why anyone would actually download it.

That gap, between something built well and something that has a reason to exist, turned out to be the most useful thing this whole project taught me. So I'm shipping it anyway, and I'll tell you why.

What I built

Zeri is a TUI multi-language REPL. You launch it, pick a language, Python, JavaScript (with Bun), Ruby, or LuaJIT, and you get an interactive session in your terminal. You can switch languages mid-session, share variables across them, save and reload your work, manage snippets, and talk to a local LLM through a command running on Ollama.

The feature list isn't the interesting part, though. The interesting part is what's underneath.

Two processes, one app

Zeri is split into two processes: a headless engine written in C++23 and a TUI frontend built in Go using Bubble Tea and Lip Gloss. The engine does all the evaluation, state, and runtime coordination. The frontend does rendering, input, and everything the user actually sees and touches. They talk to each other over a custom binary IPC protocol that I built from scratch.

Why split them at all? Separation of concerns, but taken seriously instead of as a buzzword. The engine has no idea what's rendering its output; it could be my TUI, could be a web UI, could be an editor plugin, doesn't care. And the frontend has no idea how code actually gets evaluated. The nice side effect is that the engine can't crash the UI and the UI can't freeze the engine, because each one runs its own lifecycle in its own process.

I went with C++ for the engine because I needed tight control over memory and process management, the engine spawns and babysits multiple language runtimes, and I wanted to be close to the metal for that. Go for the frontend because the Charm stack is honestly the best TUI tooling I've found in any language, with a strong focus on the UX/UI part that is necessary. Go's concurrency model makes handling async IPC messages way less painful than it would be elsewhere.

The protocol

The thing connecting the engine and the frontend is a custom binary protocol. Every message is a fixed header, payload length, message type, sequence number, and then the payload. The framing is binary on purpose: the receiver reads four bytes, knows exactly how long the message is and what kind it is, and grabs the rest without parsing anything to find where the message ends.

The payload inside that frame is just JSON, and that split is deliberate. The framing runs constantly and has to be cheap, so it's binary. The payload I want to read in a log and debug is JSON. Fast where it has to be, simple where it can afford to be. Chasing binary all the way down would've bought me almost nothing and cost me every debugging session for five months.

That protocol came out of Zeri, and I pulled it into a standalone library, Yuumi, with a dedicated SDK per language. This is turning into a real rabbit hole; we'll see how it goes.

Sidecar runtimes

Each language runs as its own sidecar process. Switch to Python, Zeri spawns or reuses a Python process; run some code, the engine routes it to the right sidecar, grabs the output, and ships it back to the frontend over IPC.

Think of it like a restaurant kitchen. The engine is the head chef calling out orders; each sidecar is a station, one does Python, one Ruby, one Lua. If the fry station catches fire, the pasta station keeps going (pasta, because I'm Italian). A segfault in one runtime doesn't take down the others: they're fully isolated, each with its own memory and lifecycle. The engine stands in the middle, orchestrating, handling timeouts and failures when a station goes down.

SharedScope

The piece I'm proudest of, technically, is SharedScope, sharing variables across languages. You set a value in Python, switch over to JavaScript, ask for it, and it's there. Sounds simple. It is absolutely not simple, and explaining why is the most interesting part of the whole engine.

The problem is that a value in one language isn't the same object in another. A Python int and a JavaScript number are different things in memory. None, null, and nil are three different ways of saying nothing. A dict isn't an object isn't a table. So there's no "just pass the variable"; something in the middle has to own a neutral version of that value that doesn't belong to any single language.

That something is the C++ engine. It works like a hub: every language runtime is a spoke, isolated, with its own namespace, and the engine sits in the center holding the canonical copy of every shared value as a language-agnostic type. When Python calls set, the value gets serialized to JSON, shipped to the engine over the protocol, and rebuilt into that neutral representation, a normalized form where every int becomes a 64-bit integer, every float a double, strings, booleans, null, and arrays and maps built recursively out of those. When JavaScript later calls get, the engine serializes its canonical copy back out, and the JS side rebuilds it natively. The runtimes never touch each other. They only ever talk to the hub.

Two things I decided on purpose matter more than they look. First, it's copy by value, not shared memory. You're not getting a live reference; modify the object on the JavaScript side, and the Python side has no idea. If you want the change to propagate, you set it again. Pretending otherwise would've meant faking shared memory across four separate processes, which is a lie waiting to crash. Second, if a value doesn't fit the supported set, a function, a class instance, a Python set, a BigInt, or an integer too large for 64 bits, the engine doesn't quietly mangle it into something wrong. It refuses, explicitly, and the set call fails on the spot. I'd rather it break loudly than hand you corrupted data three steps later.

So the scope is honest and narrow: primitives and simple containers, by value, with hard edges where the languages stop agreeing. Within that box, it's reliable, and getting it reliable, the type normalization, the failure modes, the request/response handshake underneath it, is the hardest engineering in the project.

And it works. A variable I wrote in Python, read back in JavaScript, a second later, exactly right.

It was both interesting and frustrating to create this software, and it was fitting to describe it so comprehensively. And that's precisely what makes the next section so annoying to write.

The gap

Everything worked. The handshake was clean, SharedScope moved values across languages without choking, sessions saved and restored, the AI thing answered, the TUI looked great. I could demo every single feature, and every single one did its job.

But I kept landing back on the same question, and it would not leave me alone: who is this for?

I tried to answer it honestly. A developer who works across multiple languages. Okay, but when exactly? If I'm writing Python, I open a Python REPL. If I'm in JavaScript, I open Node or Bun. The moment I need both, I open two terminals, and that costs me about three seconds. Zeri's cross-language sharing is cool, but when was the last time I actually needed to set a variable in Python and read it back in JavaScript inside a REPL? I couldn't remember it ever happening.

Session persistence is useful, except that most REPLs already have history. The AI integration is handy, except Ollama already ships a CLI. Snippet management is nice, except so is a file.

Every feature was built well. None of them answered the question. I'd built a pile of solutions and gone looking for a problem afterward.

How I missed it for five months

The honest answer is that I never asked. Zeri started as an architecture flex. "Can I build a two-process TUI app with a custom IPC protocol?"

The answer turned out to be yes, and the momentum of building just carried me for five months straight. Every week handed me a new interesting problem: serialization, process management, runtime orchestration, cross-language type mapping. I was learning C++23, learning Go's concurrency patterns, designing a binary protocol from nothing. The work was real, and I got better at my job because of it.

But not once did I stop and ask the boring question: what specific frustration does this kill? What's painful right now that Zeri makes painless?

I know what that question sounds like when it has an answer, because my other projects all have one. One of them exists because sharing dumb code before it's PR-ready is awkward; you're pasting into Slack or screen-sharing, both terrible for async. Another exists because I lose the first two hours of every new project to the same scaffolding I've done a hundred times. Another because I watch myself burn tokens sending bloated prompts to an LLM with no tooling to tighten them up first.

Each of those starts with "I keep running into this." Zeri starts with "I wonder if I can build this." That's the whole gap. Not skill, not effort, origin.

Architecture-first vs problem-first

I've started calling these two modes architecture-first and problem-first, and you can probably already guess the split: one puts solving a real problem front and center, the other aims at "the best possible architecture nobody asked for", basically a very complicated paperweight.

Problem-first: something annoys you, you build the smallest thing that makes it stop. The problem leads, easy.

Architecture-first: you build the cool thing, then go looking for a reason it should exist. The justification limps along behind; all of this breaks at some point.

Zeri is exactly this. Every other project I've ever had or started starts with a problem, even a small one. Five months of architecture, implementation, and searching for a final goal. Good execution. Beautiful. Now what?

What this actually means

I don't think this is just my problem. I think it's a recurring pattern, and I think many developers fall for it, especially self-taught developers building a portfolio, which is exactly my situation. The goal of impressing with a perfect project overrides the purpose of our development.

What five months of working alone taught me

When you're learning systems programming, there's a real high in making low-level things work. Two processes talking over a protocol you designed yourself is satisfying. Serializing values across language boundaries is a proper puzzle. The feedback loop never stops: something breaks, you fix it, you learn something, repeat. Five months of that is a genuinely good education.

The trap is that building feels like progress even when you're going nowhere in particular. Every bug fix, every feature, every refactor is real work; it's just not necessarily pointed at anything. And from the inside, you can't tell the difference.

Working solo makes it worse. Nobody reviews your code, nobody asks "wait, why are we building this this way?" You get fast at debugging, and you start trusting your own judgment, but you lose the one thing that would've saved you: someone to ask the uncomfortable question. It's like spending a whole Sunday slow-cooking a ragù from scratch, perfect technique, six hours on the stove, and only realizing at dinner that nobody at the table eats meat. Good execution, bad dish. Five months alone is exactly how you end up cooking that ragù.

So if you're building solo, find someone who'll ask why your project gets to exist at all, not about your code, but about the thing itself. Write it up and post it on Dev.to, Hacker News, Reddit. You might get the answer you couldn't reach on your own.

When to stop and ask

If I did this again, I'd put a checkpoint at the one-month mark, not to review the code, but to pressure-test the thesis. Can I say in one sentence who this is for and what it fixes? If I can't, that's not a signal to quit. It's a signal to stop typing and think. Maybe the answer's there, and I just haven't found the words. Maybe it isn't, and I should call it a learning project out loud instead of pretending it's a product. Either one is fine. Spending five months not asking is the part that isn't.

What do you do with something "empty"?

I could've just shelved Zeri. I didn't, for three reasons.

The engineering is real and worth showing. If you're building a portfolio and job hunting, something that proves you can think at the systems level has value even when it isn't a product. The lesson is worth sharing because the architecture-vs-product question barely comes up in "what I learned building X" posts, which are usually all about technical decisions and rarely about whether the thing should exist. And open source is unpredictable; maybe somebody looks at this and sees a use case I'm too close to see. Shipping it keeps that door open. Not shipping it closes it for no reason.

Where this goes

The Zeri repo is open on my GitHub: github.com/ilmartotch/ZeriReplEngine. I'm deliberately not calling this a launch, because it isn't one. It's a portfolio piece and an open invitation: here's what I built, here's what building it taught me, here's what I'd do differently next time.

Yuumi, the protocol, is turning into its own library, and funnily enough, it has a clearer identity than Zeri ever did. It's one of the better things to come out of all this. The rest of my projects are in the pipeline.

Five months. A solid architecture. One genuinely useful lesson. I'd do it again. I would simply have asked the question much earlier and differently.

I have two years of experience in this (software engineering), so take all of it with you and keep that in mind. I'm not handing down lessons from a mountain, I'm telling you what I figured out the slow and expensive way and betting it's useful to somebody a few steps behind me.

I'm ready for the salt or the flame; in the end, I'm a League of Legends player.

Top comments (1)

Collapse
 
fromzerotoship profile image
FromZeroToShip

"I couldn't come up with one honest sentence explaining why anyone would actually download it" — that sentence took real courage to publish, and it's worth more than the five months of code.

I'm an extreme datapoint from the other end of your spectrum. I'm not a developer at all (physical therapist in a hospital), yet I've shipped 20+ internal tools with AI — and the embarrassing secret is that I was incapable of the architecture-first trap. I couldn't build anything for the joy of the tech, because I had no tech joy to draw on. The only force that could drag me through a build was a specific person's specific misery: a colleague hand-copying license plates, a team burning a week each quarter on manual counting. Problem-first wasn't my discipline; it was my only fuel. Reading your post, I finally understood that my biggest limitation was quietly my biggest protection.

Your one-month checkpoint idea is good; here's the even cheaper version I use before day one: I have to be able to say the user's actual name. Not "developers who want multi-language REPLs" — a name, a face, a sigh I've personally heard. If I can't, I don't start. And for what it's worth: the five months weren't wasted. The craft transfers. The judgment about where to point the craft is exactly what you just bought, at full price, and it's the expensive half.