Write It So You Don't Hate It — and Neither Does the AI


Let’s be honest — no one wakes up excited to rebuild their frontend architecture. You usually get there after hitting enough walls. Real ones. Walls that make you question your life choices because fixing one bug means touching eight files and praying something else doesn’t break.
So this is how we are turning a complex, messy Angular app into something we could actually enjoy working on. Something predictable. Something understandable. Something that both developers and LLMs could make sense of without rage-quitting.
what we started with
Not gonna sugarcoat it — things were pretty rough. The app worked, sure, but working isn’t the same as maintainable. You know the type of codebase that slowly evolved over years with a lot of “let’s just do this for now” decisions? Yeah. That.
Some of the worst offenders:
One model file trying to define every data type in the app — nearly 1,000 lines.
Huge components doing everything: fetching data, transforming it, handling UI logic, routing, triggering side effects.
Local state sprinkled everywhere: 50+ properties living inside the component, updated manually.
A shared service that basically became a dumping ground for any kind of observable or flag we didn’t know where else to put.
Dependency Injection gone wild: some components had 10+ services injected into them.
what we did differently (and why it felt… better)
1. start with models — give them structure
That one big interface file? Gone.
We split it into focused models based on logical domains. One model file per entity or feature. Each model was typed properly, used shared base interfaces where applicable, and followed consistent naming.
We also got rid of those any
s and vague {}
types. If we didn’t know the shape, we figured it out. If something could be reused across features, we moved it to a base model.
Basically, the models stopped being a dumping ground and started being actual contracts.
2. break down the God Components
If your component has more than ~300 lines, it’s probably doing too much.
We took our giant multi-hundred-line components and broke them up. Presentation logic stayed in the component. Everything else — business logic, transformation logic, state updates — moved to services.
The new rule of thumb became: components should be dumb. They should know what to show, and when to show it. Everything else? Delegate.
3. move all state to a central place
This was huge.
Previously, every component managed its own slice of state — and not always very well. There were deep clones happening everywhere, race conditions, side effects hidden in callbacks, and zero coordination between state updates.
We introduced a centralized state service — basically a custom store using BehaviorSubject
, Observable
, and basic getter/setter patterns.
private itemsSubject = new BehaviorSubject<Item[]>([]);
public items$ = this.itemsSubject.asObservable();
getItems(): Item[] { return this.itemsSubject.getValue(); }
setItems(items: Item[]): void { this.itemsSubject.next(items); }
Now, whether it’s UI toggles, filters, selected items, or fetched data — everything flows through this single, consistent pipeline. No more guesswork.
4. use service facades instead of injecting everything
Instead of injecting a dozen services into each component, we created facades. Think of them as access points to smaller services grouped by feature.
So now, instead of a component having to know which of the 12 services to use, it talks to a facade like:
this.appServices.featureService().updateSelectedItem(item);
This makes testing, reasoning, and refactoring way easier. Plus, it makes LLMs much more effective when trying to autocomplete or infer what you’re doing — because the patterns are always the same.
what made this LLM-friendly (and human-friendly too)
Here’s the unexpected win: once our architecture became more predictable, tools like Cursor and Copilot started becoming more useful too. It wasn’t that they got better — we just gave them better patterns to work with.
Some principles we stuck to:
Always expose state as
$
observablesFollow the
privateSubject → publicObservable → getter/setter
conventionDocument the exposed methods using JSDoc
Use consistent naming across features (
selectedItem$
,filteredItems$
,isLoading$
, etc.)Keep services lean and purpose-driven
Turns out, when your code looks the same across features, both humans and machines breathe a little easier.
some actual numbers (just to prove a point)
Metric | Before | After |
Model organization | Single 984-line file | 17 focused model files |
Component size | ~950 lines | <300 lines |
any usage | ~50 scattered | <5 (justified) |
State management | Scattered across components | Centralized observables |
Subjects in shared service | 60+ | Zero |
Dependencies per component | 10+ | 2–3 max |
We weren’t trying to “optimize numbers”. We just didn’t want to fear opening files anymore.
what all this taught me
The more we worked on this, the more it became clear: good architecture isn’t about using fancy patterns or frameworks. It’s about removing friction — for the team, for future developers, and for the tools we now use every day.
And yeah, AI tools are evolving fast. But if the input we give them is a mess, they’re not going to magically fix it. They’ll get just as confused as we do.
So this isn’t just about writing clean code. It’s about writing code that’s understandable. Code that flows. Code that feels like it was written by someone who cared about whoever comes next.
Subscribe to my newsletter
Read articles from vxhlogs directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
