I'm a DevOps engineer by day. Before that, I spent half my career as a Java developer in enterprise settings — the kind where you have architects, design documents, review boards, and weeks of planning before writing a single line of code.
So when I sat down one evening with Claude Code and typed this:
"Create me a UI for WSL that allows me to view distributions, start them and shut them down or shutdown all WSL"
I wasn't expecting much. Maybe some React boilerplate. Perhaps a starting point I could iterate on over the following weeks.
Five minutes later, I had a working application.
Not a mockup. Not a skeleton. A functional Tauri app that actually listed my WSL distributions, with start and stop buttons that worked. The Rust backend called wsl.exe. The React frontend rendered a clean list. Everything was wired up.
I sat there, slightly stunned.
"Okay," I thought. "Now can we add a way to see which distributions are running?"
Done. Green badges appeared next to running distros.
"Can we add memory usage?"
Done. Resource consumption appeared in each card.
"What about exporting distributions to tar files?"
Done.
This continued for hours. Each request — no matter how ambitious — resulted in working code within minutes. Container imports. Registry manipulation. OCI image pulling. Custom actions. Dark mode. Settings pages.
I was vibe coding with an AI partner, and it was exhilarating.
The Honeymoon Phase
Those first few sessions were magical. I'd describe a feature in plain English, and Claude would:
- Understand the intent (usually)
- Find the right place in the codebase
- Implement the feature
- Handle edge cases I hadn't mentioned
- Add error handling
The speed was intoxicating. In my enterprise days, adding a simple feature meant:
- Writing a design document
- Getting architectural approval
- Waiting for a code review cycle
- Navigating the CI/CD pipeline
- Maybe, eventually, seeing it deployed
With Claude Code, I went from idea to working feature in the time it would have taken me to open Confluence.
Features piled up. The app grew from a simple list view to a comprehensive WSL management tool:
- Distribution lifecycle management (start, stop, terminate, delete)
- Import/export to tar files
- Cloning distributions
- Container imports from OCI registries
- Renaming distributions (with Windows Registry updates!)
- Custom shell actions
- Resource monitoring
- Disk mounting
- Settings management
- Light and dark themes
Each feature? Usually a single conversation. "Can we add X?" and X would appear.
The Cracks Start to Show
Around the twentieth feature, things started getting... interesting.
Not broken. Not exactly wrong. Just... messy.
I'd ask Claude to add something simple, and the implementation would land in a strange place. Or duplicate logic that existed elsewhere. Or create a new pattern when an existing one would have worked.
The problem wasn't Claude — it was me. I'd never established an architecture. There was no blueprint saying "Tauri commands go here, business logic goes there, UI state lives in this store." We were improvising, and the codebase reflected it.
Here's what that looked like in practice.
The 1,500-Line Commands File
Every Tauri backend function — every single one — ended up in commands.rs. Seventy-plus handlers, all in one file:
#[tauri::command]
pub async fn list_distributions() -> Result<Vec<Distribution>, String> { ... }
#[tauri::command]
pub async fn start_distribution(name: String) -> Result<(), String> { ... }
#[tauri::command]
pub async fn stop_distribution(name: String) -> Result<(), String> { ... }
// ... 67 more commands ...
#[tauri::command]
pub async fn update_global_wsl_options(memory: Option<String>, ...) -> Result<(), String> { ... }When the file hit 1,500 lines, scrolling became an adventure. Finding the command you wanted meant Ctrl+F and hope. Adding new features required reading through a wall of similar-looking functions to understand the patterns.
Was each command well-written? Mostly yes. Was the file maintainable? Absolutely not.
The Scattered Mock Problem
When I wanted to add E2E tests, I needed a mock mode — fake distributions so tests wouldn't touch my real WSL setup.
"Can we add a mock mode for testing?"
Claude added it. Traits for the abstractions, mock implementations that returned controlled data. Good stuff.
But the mock code ended up everywhere:
src-tauri/src/wsl/executor/
├── wsl_command/
│ ├── real.rs # Real WSL implementation
│ ├── mock.rs # Mock implementation
│ └── mod.rs
├── resource/
│ ├── real.rs # Real system monitoring
│ ├── mock.rs # Mock monitoring
│ └── mod.rs
├── terminal/
│ ├── real.rs # Real terminal launcher
│ ├── mock.rs # Mock terminal
│ └── mod.rs
└── mod.rs # Global initialization with OnceLockAnd the initialization? Global singletons with environment variable checking:
static WSL_EXECUTOR: OnceLock<Arc<dyn WslCommandExecutor>> = OnceLock::new();
fn init_executors() {
if crate::utils::is_mock_mode() {
let wsl_mock = Arc::new(MockWslExecutor::new());
WSL_EXECUTOR.get_or_init(|| wsl_mock.clone());
// ... more singletons
} else {
WSL_EXECUTOR.get_or_init(|| Arc::new(RealWslExecutor));
}
}The abstraction layer (traits!) was good. The dependency injection (global singletons!) was not. This pattern was retrofitted after the fact, and it shows.
The Monolithic Store
On the frontend, things weren't much better. One Zustand store grew to handle... everything:
interface DistroStore {
// State
distributions: Distribution[];
isLoading: boolean;
error: string | null;
isTimeoutError: boolean;
actionInProgress: string | null;
// 16+ methods mixing queries, commands, and side effects
fetchDistros: (silent?: boolean) => Promise<void>;
startDistro: (name: string, id?: string) => Promise<void>;
stopDistro: (name: string) => Promise<void>;
deleteDistro: (name: string) => Promise<void>;
openTerminal: (name: string, id?: string) => Promise<void>;
exportDistro: (name: string) => Promise<string | null>;
// ... and more
}This single store managed:
- Core distribution state
- Loading and error states
- Background operations (disk size fetching)
- Action progress tracking
- Side effects (opening terminals)
- Polling orchestration
At 539 lines, it was getting hard to reason about. Every change risked unintended side effects.
Where Claude Gets Confused
Let me be clear: Claude is remarkable. The code it writes is generally high quality. It understands context, follows patterns, and handles edge cases thoughtfully.
But it has limitations, and vibe coding exposes them.
Similar patterns lead it astray. When your codebase has multiple similar-looking functions, Claude might pick the wrong one to follow. I'd ask for a feature, and it would implement it following a pattern from a different area of the code — technically correct, but inconsistent with the nearby code.
Context gets overwhelming. In a large file like commands.rs, Claude sometimes missed that similar functionality already existed. It would create a new helper function when an existing one would have worked. Or add error handling in a different style than the surrounding code.
It optimizes locally, not globally. Each change makes sense in isolation. But without an architectural vision, the cumulative effect is entropy. Small decisions compound into structural debt.
It can't see what's not there. If you haven't established a boundary — "this module handles X, that module handles Y" — Claude can't enforce it. The architecture in your head isn't in the codebase, and Claude reads the codebase.
These aren't Claude's failures. They're mine. I asked for features without establishing structure. I vibe coded when I should have architected.
The Architecture Docs That Came Too Late
Eventually, I sat down and wrote what I should have written at the start: architecture documentation.
Two comprehensive documents now exist:
Backend: Hexagonal Architecture — A ports and adapters design for the Rust backend, with clear domain boundaries. The core idea? Your business logic shouldn't know or care whether it's talking to a real WSL installation or a mock for testing.
Frontend: Feature-Based Structure — A feature-based architecture with clean separations between state, services, and presentation. Each feature owns its components, hooks, and state — no more hunting through global folders.
Writing these was painful because it meant acknowledging the debt. The documents include tables like this:
| Aspect | Current State | Issue Severity |
|---|---|---|
| commands.rs | 1,147 lines, 70+ handlers | High - violates SRP |
| Dependency injection | Global singletons via OnceLock | Medium |
| Service layer | Anemic WslService facade | Medium |
| Store structure | Monolithic distroStore | High |
That's the architectural equivalent of a code review that comes back all red.
The Contrast: Starting Fresh with Architecture
I recently started another personal project. This time, I did it differently.
Before writing any code, I spent a few hours with Claude discussing architecture:
"I'm building a [project]. Let's design the architecture before we code. I want hexagonal architecture for the backend, with clear port and adapter boundaries. On the frontend, I want feature-based modules with domain-driven boundaries."
We talked through:
- Domain entities and value objects
- Port interfaces and adapter implementations
- Module boundaries and dependency rules
- State management patterns
- Error handling strategies
Then I had Claude generate the folder structure and key interfaces — without implementations. Just the skeleton. The blueprint.
Only then did we start building features.
The difference is night and day.
When I ask for a new feature now, Claude knows exactly where it goes. The architecture documentation is part of the context. The folder structure enforces boundaries. The existing patterns guide new code.
There's no 1,500-line command file because commands are grouped by domain. There's no monolithic store because each feature has its own state slice. Mock implementations live in a dedicated testing infrastructure, not scattered through production code.
Development is actually faster now, not slower. The upfront investment in architecture pays dividends every single day.
Structured Vibe Coding: The Backlog Approach
Here's the thing about pure vibe coding: the ideas come fast. You're in flow state, one feature sparks another, and suddenly you have a mental list of twenty things you want to build. That energy is valuable — you don't want to kill it with process.
But you also don't want to context-switch every ten minutes chasing the next shiny idea.
My solution? A lightweight backlog. Not Jira. Not story points. Just a simple list that captures ideas as they flow, then lets me work through them systematically.
When a new idea hits mid-feature, I don't immediately pivot. I add it to the backlog:
## Backlog
### In Progress
- [ ] Add disk mounting support
### Up Next
- [ ] Custom shell actions per distribution
- [ ] Resource monitoring graphs
### Ideas (unrefined)
- Registry backup before modifications?
- Bulk operations on multiple distros
- Import from Docker Hub directlyThen I finish what I'm working on before starting the next item. Revolutionary, I know.
The magic is in the discipline of finishing. Each feature gets:
- Implementation
- Error handling
- UI feedback (loading states, success/error messages)
- Basic testing
No half-done features littering the codebase. No "I'll add error handling later" (you won't). Each item ships complete before the next one starts.
This isn't waterfall. There's no sprint planning meeting. No velocity tracking. No retrospectives. It's just me, Claude, and a text file — but with enough structure to avoid the chaos of pure improvisation.
The backlog also helps Claude. Instead of describing a feature from scratch each session, I can say:
"Let's work on the next backlog item: custom shell actions per distribution. Here's what I'm thinking..."
Context preserved. Momentum maintained. Vibe intact.
What I'd Tell Past Me
If I could go back to that first evening with Claude, here's what I'd say:
1. Spend an hour on architecture first.
Not a full design document. Not enterprise architecture astronautics. Just:
- Where do backend commands live? (Grouped by domain)
- How is state managed? (Separate stores per feature)
- Where do side effects go? (Not in stores)
- How will you test? (Dependency injection from day one)
2. Create the folder structure before the first feature.
Empty folders with README files explaining their purpose. It takes ten minutes and saves hours of refactoring.
3. Establish patterns early, document them.
When Claude implements the first feature, decide if you like the pattern. If yes, document it. If no, refactor immediately before it spreads.
4. Review periodically.
After every five features, step back. Is the codebase staying organized? Are patterns consistent? Is anything growing too large?
5. Use Claude for architecture discussions, not just coding.
Claude is excellent at discussing trade-offs, explaining patterns, and suggesting structures. Use that capability before the keyboard comes out.
The Verdict on Vibe Coding
Would I do it again? Absolutely.
Vibe coding with Claude is phenomenal for:
- Prototypes and MVPs
- Personal projects where you're learning
- Exploring possibilities quickly
- Getting something working when motivation is high
But I'd pair it with minimal upfront architecture. Not enterprise waterfall. Not months of planning. Just enough structure to guide growth.
The sweet spot is probably:
- One hour of architecture discussion
- Folder structure and key interfaces generated
- First feature implemented following the patterns
- Then vibe code to your heart's content
Claude will follow the patterns. It'll place new code where it belongs. It'll maintain consistency because consistency exists to maintain.
Try It Yourself
Claude Code is available now. It's genuinely transformative for software development — the fastest I've ever gone from idea to working software.
WSL-UI is open source on GitHub and free on the Microsoft Store. Will I actually rearchitect it? Honestly, probably not. It works, it's free, and unless it gains unexpected popularity, the effort might not be justified. But that's okay — the real value was the journey. Learning Tauri, navigating Microsoft Store publishing, building E2E testing infrastructure, understanding what good architecture should look like. Those lessons transfer directly to the next project.
And the next time you sit down with an AI coding assistant, maybe spend that first hour talking about architecture instead of features. Future you will be grateful.
Built with Claude by Anthropic. Because the best code comes from great conversations.