Vibe Coding is Cooling Off, Fast

Vibe Coding is Cooling Off, Fast

AI is great for PoCs, but fails at creating maintainable software

The title might sound a bit like clickbait, but even the guy who coined the term “vibe coding,” Andrej Karpathy, just pointed out that he hand-wrote his latest project. Whatever his reasons, the bottom line is: vibe coding just wasn’t good enough.

  • Feb 3, 2025: The term “Vibe coding” is born.
  • Oct 13, 2025: Andrej Karpathy gives an update on vibe coding.

That’s barely half a year. What the hell happened?

We were just celebrating how insanely capable Claude 4.5 and GPT-5 are, and suddenly the script flips?

Honestly, I’ve also been hitting the hard limits of vibe coding myself lately.

Let me provide a real-world example.

Let’s say we have a huge project and we need to introduce a behavior change. Whether it’s for A/B testing or feature toggling, we’re essentially just wrapping the old code in an if-else block to introduce the new behavior, right?

That’s a bit abstract, so let’s look at some pseudo-code.

1
2
3
4
5
6
if is_enabled("feature A"):  
# new behavior / new feature
something_new()
else:
# original codeflow
something_old()

Super easy to understand, right?

Now, when this feature is big enough, these if-else blocks are going to be scattered all over the codebase. What do we think an AI Agent will do?

Yes. Exactly. It’s going to slap an if-else on every single place that needs to change. So, what happens when the feature toggle rollout is complete and it’s time to clean up the codebase?

Right again. The Agent will just go back and rip out all those if-else statements. What’s the problem with that? If that if-else is spread across 100 different files, this “housekeeping” task is now touching 100 files. Would we feel safe pushing that change to production?

So, what’s the right way to do it?

I mentioned this in a previous article on feature toggling: the correct practice is to introduce the Factory Pattern.

The beauty of this is that after the code is refactored, the actual feature flag check is hidden inside the factory. When it’s time for cleanup, we only have to modify the factory. We don’t have to touch all the integration points.

Let’s look at a concrete example.

The Factory Pattern in Practice

Let’s say we have some business logic for getting user data. The old implementation queries the database directly. The new implementation uses a dedicated microservice. We need to migrate all the database queries to the new REST API.

1
2
3
4
5
6
7
8
9
def get_user_profile(customer_id: str, email: str):      
if feature_flags.is_enabled(customer_id, 'USE_NEW_USER_SERVICE_FF'):
# If the flag is on, call the new API service
user_data = call_new_api_service(email)
else:
# Otherwise, query the old database
user_data = query_legacy_database(email)

return user_data

If we also have functions to get group info by user_id or other queries, we can imagine this if-else getting scattered everywhere.

This is where we bring in the Factory Pattern. It has three core components: an Interface, Concrete Implementations, and a Factory.

First, we define an abstract UserProvider interface. It defines the methods that all “user data providers” must have. This way, the calling code doesn’t need to care if the data is coming from a database or an API.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from abc import ABC, abstractmethod  
from typing import Optional, Dict, List, Tuple

class UserProvider(ABC):
"""
Abstract interface for a user data provider.
Defines methods all concrete providers must implement.
"""
@abstractmethod
def get_user_by_email(self, customer_id: str, email: str) -> Optional[Dict]:
"""Gets user data by Email"""
pass

@abstractmethod
def get_user_groups(self, customer_id: str, user_id: str) -> List[str]:
"""Gets a list of groups for a user ID"""
pass

Next, we create concrete classes for the old and new services, both of which implement the UserProvider interface.

The old database service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class OldDatabaseUserProvider(UserProvider):  
"""
Implementation for fetching user data from the legacy database.
"""
def get_user_by_email(self, customer_id: str, email: str) -> Optional[Dict]:
print(f"--- (Legacy System) Querying DB for Email: {email} ---")
# Simulate DB query...
if email == "test@example.com":
return {'user_id': 'db_123', 'email': email, 'name': 'DB User'}
return None

def get_user_groups(self, customer_id: str, user_id: str) -> List[str]:
print(f"--- (Legacy System) Querying DB for User ID: {user_id}'s groups ---")
# Simulate query...
return ['group_a_db', 'group_b_db']

The new API service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class NewAPIUserProvider(UserProvider):  
"""
Implementation for fetching user data from the new microservice API.
"""
def get_user_by_email(self, customer_id: str, email: str) -> Optional[Dict]:
print(f"--- (New System) Calling API for Email: {email} ---")
# Simulate API call...
if email == "test@example.com":
return {'user_id': 'api_xyz', 'email': email, 'name': 'API User'}
return None

def get_user_groups(self, customer_id: str, user_id: str) -> List[str]:
print(f"--- (New System) Calling API for User ID: {user_id}'s groups ---")
# Simulate API call...
return ['group_x_api', 'group_y_api']

Finally, we create a UserProviderFactory. Its only job is to decide which concrete UserProvider instance to return based on the given conditions (here, the customer_id and his feature flag).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class UserProviderFactory:  
def __init__(self):
# Pre-build and cache the instances to avoid re-creation
self._providers = {
'database': OldDatabaseUserProvider(),
'api': NewAPIUserProvider()
}

def get_provider(self, customer_id: str) -> UserProvider:
"""
Decides which provider to return based on the feature flag.
This is the core of the pattern, centralizing the "decision logic".
"""
if feature_flags.is_enabled(customer_id, 'USE_NEW_USER_SERVICE_FF'):
print("Factory Decision: Using NewAPIUserProvider")
return self._providers['api']
else:
print("Factory Decision: Using OldDatabaseUserProvider")
return self._providers['database']

Now, we’ve completely encapsulated the object creation and selection logic within the factory.

After implementing the factory, the business logic function that was full of if-else statements becomes way simpler.

The client code no longer needs to care about “which service to use” or “how to build the service object.” It just asks the factory for a usable UserProvider.

1
2
3
4
user_provider_factory = UserProviderFactory()  
provider = user_provider_factory.get_provider(customer_id)

user_data = provider.get_user_by_email(customer_id, email)

In the future, when it’s time to clean up, we only need to touch the feature_flags.is_enabled line in the factory and delete the legacy OldDatabaseUserProvider. This ensures all client code is unaffected and dramatically shrinks the cleanup’s blast radius.

Here’s Another Thing the Agent Won’t Tell Us

Let’s do one more example.

Today’s AI agents are smart. They even write tests for their code. But if we actually look at the tests they write, we’ll find they are full of duplicated code, especially when they’re trying to cover a bunch of corner cases.

These kinds of unit tests are basically unmaintainable. A future logic change could instantly break a ton of test cases.

What’s the right way to do it? As I’ve explained in a previous article, we need to use table-driven testing to consolidate all the test cases.

A unit test follows the Arrange-Act-Assert (AAA) pattern. For different test cases, the only parts that really change are ‘Arrange’ (the inputs) and ‘Assert’ (the expected outputs). So, we can just build a table (an array) of all the cases and run them all through a single loop.

In fact, a lot of testing frameworks have-built in support for this. For example, pytest does this with mark.parametrize.

1
2
3
4
5
6
7
8
9
10
11
12
13
import pytest  

@pytest.mark.parametrize("expression,expected",
[
("2 + 3", 5),
("2 + 3 * 4", 14),
("(2 + 3) * 4", 20),
# and so on
]
)
def test_calculator(expression, expected):
ret = calculator(expression)
assert ret == expected

The agent won’t tell us any of this.

Not unless we explicitly tell it: “I want us to implement the Factory Pattern” or “I want us to use table-driven testing.”

And this is the limit of vibe coding. I’ve spent a lot of time in the past discussing the practical details of vibe coding, and I was an early adopter of spec-driven development workflows.

But when I seriously review the AI’s output, I find that even with something as powerful as Claude 4.5, the code it produces is still full of redundancy. This redundant code might be correct, and it might even work, but over time, it will become completely unmaintainable.

Both humans and AI have a limited context window.

Ultimately, software development still comes back to good software engineering practices.

We need to discuss design patterns. We need to consider feature onboarding and all the other real-world architecture problems. Vibe coding is great for building a PoC (Proof of Concept) really fast. But a PoC is not an MVP. The moment we need to turn that PoC into a real product, we still have to rely on the experience and wisdom of a human software engineer to solve those real-world problems.

Wrap Up

We used two concrete examples to explain the limits of vibe coding: one with design patterns and one with table-driven testing.

We might be thinking, “Well, can’t we just define all these rules in the system instructions?” That’s… yes and no.

There are so many design patterns. If we don’t specify which one, can the AI really pick the right one? What we call “design patterns” are split into three major categories — Creational, Structural, and Behavioral — each with dozens of different patterns.

Remember, the AI has a limited context. In a massive codebase, it needs to identify the problem, break it down, and propose the right solution. I don’t need to tell us how hard that is.

And that’s just talking about the narrow definition of design patterns. That’s not even getting into Pattern-Oriented Software Architecture or all the higher-level architectural problems like microservices, event-driven architecture, etc.

These are not problems an AI can solve just by glancing at a codebase or reading a detailed spec.

Don’t get me wrong, I’m still going to use vibe coding. But for me, its value right now has been reduced to just a super-powered auto-complete.

To put it bluntly, the auto-complete that AI gives me is just a lot more flexible than the old-school, rule-based completion. But at the end of the day, that’s all it is.

A message from our Founder

Hey, Sunil here. I wanted to take a moment to thank you for reading until the end and for being a part of this community.

Did you know that our team run these publications as a volunteer effort to over 3.5m monthly readers? We don’t receive any funding, we do this to support the community. ❤️

If you want to show some love, please take a moment to follow me on LinkedIn, TikTok, Instagram. You can also subscribe to our weekly newsletter.

And before you go, don’t forget to clap and follow the writer️!

Originally published on Medium