CT Wu

Software Architect · Backend · Data Engineering

Solving Gevent Pool Issues in Flask Apps

Learn how Keep-Alive connections can drain your Gevent pool and crash pods

Recently, we have been troubleshooting a stubborn issue. Pods kept restarting for no apparent reason. The cause was liveness and readiness probe failures.

This sounds common, right? But here is the catch. We had already tuned our liveness and readiness probes to be as simple as possible. Like this:

1
2
3
@app.route('/health/check')  
def check():
return 'Hello, World!'

It always returns HTTP 200 OK. Yes, always.

Even with this setup, we still saw liveness and readiness failures.

Before we dig into the problem, let’s look at the microservice’s infrastructure. First, it is a Flask application. However, we aren’t using Gunicorn for concurrency. We are using Gevent.

This means each pod is effectively a single-process Flask app. It uses coroutines, or “greenlets,” to handle each request. Now that we understand the setup, let’s analyze the root cause layer by layer.

Question 1: Why does such a simple health check fail?

Logically, a “Hello World” response shouldn’t trigger errors or timeouts. So why did the health check fail?

We have to look at the design. To prevent resource exhaustion, we configured a Gevent pool. Whenever a request comes in, we grab a greenlet from the pool to handle it. If the pool is empty, we can’t handle any requests. Not even the simple ones.

Take a look at the graph below.

The red line shows the number of pod restarts. The other lines show the remaining capacity of the Gevent pool, which keeps dropping. When the pool hits zero, the app can’t respond to health checks. This triggers a pod restart.

After the restart, the Gevent pool in the new pod becomes available again, and it can resume service.

Question 2: Why does the Gevent pool run out?

We know the immediate cause. Now we need to find out what is consuming the Gevent pool. We need to understand why we ran out of greenlets.

At first, we suspected high CPU usage. If the CPU spikes, every request takes longer to respond. This would mean each greenlet is occupied for more time.

Eventually, this creates a slow leak.

The overlay of the Gevent pool and CPU usage seemed to support this theory.

However, further analysis proved us wrong. Even when the CPU spiked, the peak usage never hit the full CPU request limit for the pod. We also didn’t see any signs of CPU throttling. So, this hypothesis didn’t hold up.

Question 3: Is it a Gevent leak?

To figure out what is holding onto the greenlets, we need to dump the current greenlet stack. Fortunately, Gevent provides a tool for this: gevent.utils.print_run_info.

We used this to analyze the dump and see what each greenlet was doing. We found a huge number of greenlets waiting on read_requestline.

Below is a breakdown of the code flow.

This is typical HTTP Keep-Alive behavior. If a client keeps the connection open but doesn’t send a request, the greenlet waits indefinitely.

Now we have our answer. The greenlets were being forced to stay alive because of the Keep-Alive setting. This eventually drained the Gevent pool.

Question 4: Who is hogging the connections?

We have another way to prove that Keep-Alive is the culprit. We can use netstat to check the status of the TCP connections.

First, let’s verify if there really are persistent TCP connections.

1
2
$ netstat -tn | grep ':8080' | awk '{print $6}' | sort | uniq -c  
234 ESTABLISHED

The results show 234 established connections.

But who established them? We can use netstat again to find out.

1
2
3
4
$ netstat -tn | grep ESTABLISHED | awk '{print $5}' | cut -d: -f1 | sort -u  
172.24.1.100
172.24.1.101
...

Now we know the source IPs. We can analyze who these sources are and figure out how to handle them.

Wrap Up

In fact, there are more questions to answer:

  • Question 5: Who exactly is the source?
  • Question 6: Why do they open connections but not close them?

However, these answers depend on the specific infrastructure environment. I won’t go deeper into those details in this article.

Still, this Root Cause Analysis process should apply to almost any web service. It is especially useful for Python services. By following the flow in this article, we don’t have to blindly suspect CPU or memory issues every time something goes wrong. We have the tools and methods to track down deeper problems.

Let’s call it a day.

Originally published on Medium

Abandoning SDD: My New AI Coding Workflow

Discover why I ditched SDD for an efficient Vibe Coding workflow

I recently made some major optimizations to the workflow I use for running development tasks with AI agents. I’ve completely abandoned SDD (Spec-driven Development) and shifted to pure vibe coding.

Even though I used to be a big believer in SDD, I’ve realized its limitations and the new problems it creates over the last few months. I’ll summarize the reasons for the switch and what we’ve found.

There is actually an article on Martin Fowler’s blog that analyzes SDD tools and processes in depth. I happened to switch my process before seeing that article. It turns out the content mirrored my feelings perfectly, so I highly recommend reading it.

Back to the main topic. Why give up SDD completely? Let me summarize it in one sentence.

Using SDD for small changes is overkill. Big changes can’t be explained clearly. Even if the documentation is written perfectly, the Agent can’t fully absorb it. Eventually, the documentation and the code drift further apart.

Let’s discuss each of these points in detail.

Small changes are unnecessary

The small changes we refer to here are usually things like receiving a bug report or a feature enhancement. This could be tweaking parameters or adjusting part of a flow.

When we need to execute this kind of small task, let’s take Kiro as an example. Would we use vibe mode or spec mode?

When I was a devout SDD believer, I always used spec mode. And what did that get us?

Three lines of code changed. Over three hundred lines of documents generated.

I don’t know if you’ve had experience using SDD tools, but even for these tiny tasks, their requirement documents still cover backward and forward compatibility, security assessments, performance reviews, and rollout plans.

Are you kidding? The time it takes to read the generated requirement document far exceeds the time it takes to just jump in and write the code myself.

Big changes aren’t explained clearly

Big changes are the flip side of small changes.

When we need to make a change involving thousands of lines (allow me to use numbers for clarity), this scenario is usually where SDD is supposed to shine, right?

The reality is different. When we give a PRD directly to a spec mode agent, it can’t produce a complete, well-broken-down task list. It easily misses things. And when a massive list actually gets to the agent for implementation, it turns out to be something entirely different.

If we have to teach the agent to build a complete list through interaction and conversation, why not just grab the list and do it ourselves?

More importantly, the agent’s context window is limited. When action items aren’t small or precise enough, it will still implement things according to its own ideas, even with a complete design doc.

This turns into a situation where the agent writes bad specs, and we need to assist it in writing them. Then, once the agent has the specs, it doesn’t follow them well, so we have to supervise it. We basically end up becoming the maintainers of the specs ourselves, which adds a lot of extra effort.

Agents can’t fully absorb even clear documentation

There are already many papers analyzing this. I’ve even done quite a few experiments and verifications myself.

You can refer to my articles and open-source projects for details.

Simply put, the agent won’t treat the design document as an absolute law. It only treats the design document as a guideline. Sometimes, it doesn’t even read the whole thing.

The result is that we spend a lot of time discussing a perfect design with the agent. But in the end, we still need to watch over the agent constantly to make sure it doesn’t go off track.

Documentation and code drift apart

This is the fate of software development. It applies to the past and the future.

Because documentation only increases and the codebase only gets bigger. No one can remember all the content in the documentation. And no one can update the relevant documents with every single change.

Whether it’s the Wiki and Confluence of the past or design docs written inside the codebase, they share the same fate. Eventually, they will no longer be consistent with the code.

The problem lies exactly here. When Wiki or Confluence is inconsistent, we humans might notice it or we might just ignore those documents. But the stuff you write in .kiro/steering gets pulled out and referenced by the agent repeatedly. If the document content is outdated, sadly, the agent’s changes will be a disaster.

How do I do it now?

Having said so much about the downsides of SDD, how do we handle things now?

I believe thinking before coding is essential, whether we are using AI coding or not. Therefore, I still keep my past habits. I analyze and design the modifications I’m about to make. The difference is that in the past, we needed to know every aspect of the codebase to make a complete analysis. But now, collaborating with an Agent allows us to analyze the chain of changes and the scope of impact more efficiently.

Once I’ve run through the simulation in my head, I list all the items to be executed in my own notepad. Then, I tell the agent to execute them one by one using vibe mode.

Where is the difference? It lies in the granularity. The items in my notepad are precise down to which file, which module, or even which function. And I tell the agent exactly where to make what kind of changes with that same precision.

Why do we need to be so precise?

Because I found that AI’s taste in code is still worse than mine. The code it writes looks incredibly ugly to me personally. It has no aesthetic sense at all. So I tell it exactly what to do and how to do it.

We’ll find that vibe coding is really just a fancier auto-completion for me. But I think that’s enough. Facts have proven that this level of efficiency boost is sufficient.

Wrap Up

Why did I make this shift in workflow?

Thinking back, the straw that broke the camel’s back was that Claude loves writing documents for absolutely everything, big or small. Every document is long and tedious. It got to the point where reading them was painful, but not reading them felt wrong too.

Instead of torturing ourselves like this, let’s just ditch these intermediate documents. For me, they have no reference value.

The motivation driving me to slowly transition to this new workflow is something I’ve mentioned before. It’s simply that the code written by AI looks too ugly. It’s full of bad smells.

Rather than dealing with that, I’d really prefer to write it myself, or make the AI write it strictly according to what I say.

Originally published on Medium

Traditional silos limit the productivity gains unlocked by AI workflows

As I mentioned in my previous article, I recently made a career shift. I moved from being a pure software architect to a hands-on staff role. This means I am now deeply involved in implementation.

I made this adjustment because there were things I wanted to verify. For instance, I wanted to see how much productivity an AI-powered workflow could unlock. The results proved that the efficiency gains are significant.

This led me to think about another question.

Can traditional organizational structures still serve their purpose?

The “traditional” structures I am referring to fall into two categories:

  1. Organizations divided by function, such as PM, ENG, QA, etc.
  2. Organizations divided by Cross-functional teams. For example, a Scrum team has a PO, developers, and QA. This might seem new rather than traditional to some people. Nevertheless, I believe this is still a traditional setup from the era before AI entered our workflows.

I raise this question because AI empowers us to increase productivity to a dramatic extent. However, the silos created by traditional organizational structures work against this boost.

There are many reasons for these silos. We have functional silos between roles. We have cognitive silos between different feature modules. And we have responsibility silos between departments. These boundaries used to seem natural. Now, AI breaks them down easily.

Let’s look at an example. We often used domain boundaries or microservice boundaries to split organizations. When Team A’s changes touched code owned by Team B, it required a lot of communication and coordination. Even development and deployment created dependencies.

Waiting is inevitable in this process. This constitutes waste.

But if this traditional organizational structure is broken, how should we define boundaries? Or simply put, how do we clarify responsibilities?

I don’t have the answer yet. I am still looking into it.

Originally published on Medium

Why I switched from architect to developer to validate Gen AI in practice

In the software development track, you have a lot of options. The main choice is between heading down the architect path or focusing on the developer track.

I chose the architect path for the last few years. My work focused on research, software architecture transformation, and pilot planning. But recently, I switched back to a developer role. I still wear the architect hat sometimes, but I spend most of my time on feature development and troubleshooting.

A lot of people have asked me why I made this switch. I wanted to use this article to talk about the reasons behind it.

If you’ve been following me, you might have noticed I started covering Gen AI topics late last year. That’s also when I began seriously studying how to use AI to boost productivity.

However, in my previous role as a pure architect, I rarely worked on production code. This meant that even though I had all these theories, I could only test their real-world effectiveness on my side projects.

This presents a problem: theory and practice start to drift apart. It’s a common dilemma for many researchers.

Therefore, switching to a more hands-on role was a better choice. This way, I can actually validate whether my methodologies work in practice.

Over the past few months of doing this, I’ve found that Gen AI really does significantly boost my productivity in many areas.

I’ll list a few of the most obvious examples.

Quickly Onboarding to Brownfield Projects

I’ve briefly explained how I do this in a previous article. It mainly involves two tools: a Coding Agent and RAG.

I’m using very general terms here because everyone’s familiar tech stack is different. For my Coding Agent, I use Copilot. Of course, you can use Claude Code, Cursor, or any other tool.

And for RAG, I use NotebookLM.

My specific process is to create a Notebook for each feature. Inside each Notebook, I put the feature’s PRD, design document, technical support KBs, and code tracing.

The technical documents are pretty straightforward. But what is code tracing?

It’s a complete report I generate using my coding agent. It includes the full e2e flow for all of the feature’s entry points, the data flow, and all related data tables. Even the e2e flow is broken down in great detail, right down to the module, class, and function level.

With this material, the Notebook RAG can quickly answer almost any basic question about the project. It can even pinpoint the code locations involved in specific problems.

As a result, I didn’t need to spend much time getting up to speed on a large, unfamiliar project before I could start contributing.

Efficient Feature Iteration

One of the most common scenarios for a developer is feature iteration.

If you think I’m going to say my coding agent lets me finish programming at lightning speed, you’re going to be disappointed.

I use AI in two ways in this scenario. First, I’ll write a very rough, quick, handwritten design document. Then, I use Gemini’s OCR capability to convert it into a fully formatted document.

Designing and developing a feature requires a lot of communication and discussion upfront. I use this method to quickly create discussion materials, meeting notes, and design docs. This really saves me a ton of time.

I can use pen and paper, which I’m most comfortable with, to turn my ideas into digital documents. This is the first major improvement.

Second, during the actual software development, I already have a complete design. This includes design patterns, API interfaces, and so on. The entire development process is broken down into small enough units. This makes it very easy for me to use AI for auto-complete.

As for why I don’t let the AI develop everything and only use it for minimal assistance, I explained that in detail in a previous article. I won’t repeat it here.

Various Daily Routines

As a software engineer, I think tedious tasks take up a big part of the day. I’ve automated a large portion of this grunt work.

Let me give a few examples. I currently work across different time zones, so someone is basically working at all hours of the day.

  1. Waking up to hundreds of emails? My solution is not to read them. I have AI quickly categorize them. It leaves only the emails I truly need to know about and those that require action. It then provides a consolidated summary so I can see what I need to do at a glance.
  2. What about all the recorded meetings I missed? I speed-watch them. I use AI to generate and embed captions in the video. This allows me to watch them at 2x or even 4x speed.
  3. What about all the routine maintenance work? I use AI to generate small tools for me. I even build automated workflows to handle them. This prevents me from having to do so much manual work.

I don’t use AI in a very generic way. It can’t do everything. But it’s more than enough to solve my specific pain points.

Troubleshooting

Debugging. I believe this is the one thing all developers hate the most.

It’s the same for me. A big part of why it’s so annoying is that we have to guess based on various clues. After guessing, we still have to find evidence among those clues to prove it.

It is absolutely tedious and mundane work.

The worst part is digging for evidence in thousands, or even tens of thousands, of log lines. It’s never an easy job, especially when the logs are unstructured. Now, I’ve offloaded this painful task to AI. We used to need grep, sed, and awk to process logs just to find keywords and check a timeline.

Now, I just need to tell the AI my intent, what I’m looking for. It can take over this dirty work for me.

Once it finds the relevant logs based on my intent, the guessing starts. I figure it’s easier to have a group guess than to do it alone. So, I feed these logs to several agents and let them all guess. Then we compare their answers.

By combining all their hypotheses, I can go back and ask the AI to find evidence in that vast sea of logs.

All of this dirty work? I don’t have to do it myself anymore.

Wrap Up

What I’ve mentioned above should cover most of the different aspects a software engineer deals with.

How do you use AI to boost productivity?

I believe the answer is simple. Give it the annoying work you hate doing. Your productivity will naturally go up.

But there’s one thing to keep in mind. I’ve stressed this point repeatedly in the past: AI isn’t brilliant. It’s just a tool. We must be able to master the tool to get the job done right.

Especially, remember that AI makes mistakes in many situations. We have to review the critical parts repeatedly. For example, when I ask Gemini to turn my handwritten draft into a design document, I still need to manually fix many details. But this is still much faster than writing one from scratch.

As for how much productivity has improved with AI, I don’t have exact numbers. Software engineer time estimates are rarely accurate anyway. But my gut feeling is at least a 2x boost. What does that mean? A huge modification that used to take two months now only takes one.

Originally published on Medium

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

Python DAG: Retries, Rollbacks & Parallel Runs

Enhance lightweight DAG with robust features for production-ready workflows

The previous article stopped at building a lightweight DAG with the Command pattern. That alone already made long functions more approachable and easier to test. Over the past week I kept iterating on the idea, because real systems rarely end with a single happy-path runner.

Three concerns show up almost immediately in production code: transient failures, partial completion, and throughput. They translate nicely into three extensions on top of the original runner: retry, rollback, and parallel execution.

Before diving into the new capabilities, here is the baseline runner from last week that simply walks the graph in topological order:

1
2
3
4
5
6
7
8
9
10
11
12
13
from graphlib import TopologicalSorter  

class DAGRunner:
def __init__(self, graph_definition: dict, commands: dict):
self.ts = TopologicalSorter(graph_definition)
self.commands = commands

def run(self):
context = {}
self.ts.prepare()
for task_name in self.ts.static_order():
self.commands[task_name].execute(context)
return context

This runner is the foundation for every extension in this article.

What matters here is that all three enhancements live inside the runner. The commands stay thin, the workflow definition stays declarative, and the abstraction keeps paying off. Let’s walk through each scenario.

Handling retries without polluting business logic

External integrations fail from time to time. The naive solution is to wrap every command in its own retry loop, but that instantly clutters the code we just spent effort to clean up. Moving retries into the runner keeps the commands focused while letting us tune policies centrally.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import time  
from graphlib import TopologicalSorter

class DAGRunnerWithRetry:
def __init__(self, graph_definition: dict, commands: dict, *, max_retries: int = 3, retry_delay: float = 1.0):
self.ts = TopologicalSorter(graph_definition)
self.commands = commands
self.max_retries = max_retries
self.retry_delay = retry_delay

def run(self):
context = {}
self.ts.prepare()
for task_name in self.ts.static_order():
command = self.commands[task_name]
last_error = None
for attempt in range(1, self.max_retries + 1):
try:
command.execute(context)
last_error = None
break
except Exception as exc:
last_error = exc
if attempt < self.max_retries:
time.sleep(self.retry_delay)
if last_error is not None:
raise last_error
return context

The graph still decides the order, while the runner handles the repeated attempts. Any transient exception is retried in place; once the configured attempts are exhausted, the original error propagates.

Rolling back after a failed step

The next requirement is undoing work if a later step fails. The Command pattern already hinted at this with its symmetric interface, so introducing undo is a natural extension. The runner simply needs to record the successful commands and process them in reverse when an error bubbles up.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from graphlib import TopologicalSorter  

class DAGRunnerWithRollback:
def __init__(self, graph_definition: dict, commands: dict):
self.ts = TopologicalSorter(graph_definition)
self.commands = commands

def run(self):
context = {}
successful_commands = []
try:
self.ts.prepare()
for task_name in self.ts.static_order():
command = self.commands[task_name]
command.execute(context)
successful_commands.append(command)
except Exception as exc:
for command in reversed(successful_commands):
command.undo(context)
raise exc
return context

Each command now implements both execute and undo. When a later task fails, the runner cleans up previously completed work in last-in-first-out order, mirroring the graph execution.

Unlocking parallel branches

Many workflows contain independent branches that can safely run side by side once their prerequisites finish. The original runner executes nodes sequentially even when the graph exposes available concurrency. Switching to a thread pool allows the runner to schedule every ready node without rewriting the business logic.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from concurrent.futures import ThreadPoolExecutor, as_completed  
from graphlib import TopologicalSorter

class DAGRunnerWithParallel:
def __init__(self, graph_definition: dict, commands: dict):
self.ts = TopologicalSorter(graph_definition)
self.commands = commands

def run(self):
context = {}
self.ts.prepare()
with ThreadPoolExecutor() as executor:
futures = {}
while self.ts.is_active():
for task_name in self.ts.get_ready():
command = self.commands[task_name]
futures[executor.submit(command.execute, context)] = task_name
for future in as_completed(list(futures)):
task_name = futures.pop(future)
try:
future.result()
self.ts.done(task_name)
except Exception:
for pending in futures:
pending.cancel()
raise
return context

The runner keeps asking the topological sorter for ready nodes, submits them to the executor, and marks each task as complete once the future finishes. If any parallel branch fails, the remaining futures are cancelled and the error is surfaced immediately.

Wrap Up

All three extensions reuse the same two abstractions introduced earlier: the DAG encodes dependency, and Command encapsulates each operation behind a stable interface. Because the runner owns orchestration, we can iteratively add capabilities such as retry strategies, rollback semantics, and parallel scheduling without rewriting domain logic.

That’s the real payoff of the abstraction. Once the seams are in place, new requirements stay localized, the codebase remains comprehensible, and future experiments can continue to build on the same foundation.

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

Tame complex workflows and simplify testing with the Command design pattern

Reviewing some legacy code lately has given me a headache.

The main issue is one massive function crammed with every piece of logic spread out flat inside it. I have to say, this is what I consider the worst kind of “code smell.”

You might ask, “But this function works fine, so what’s wrong with it?”

If it were a static function with no further iterations needed, that’d be fine. But if this function still requires iterations, or worse, isn’t even stable yet with a bunch of process bugs still needing fixing, then it’s absolutely terrible.

Modifying the flow of a massive function is challenging, whether adding conditional checks or introducing new steps. First, developers must understand every existing process to grasp the context and make changes.

After spending tremendous effort to understand the function and complete the modification, the next problem is how to test it.

If it’s a massive function, I believe its unit tests are likely filled with various mocks. Once part of the logic is modified, the worst-case is that all unit tests fail. Moreover, these failures aren’t due to test scenario issues, but simply because the mocks no longer work.

The book Refactoring also ranks Long Methods very high among code smells.

To sum up, a massive function causes numerous problems. So, are there any effective approaches to mitigate this?

Abstraction

Software development is essentially a series of abstraction processes.

Therefore, the most common approach to solving the Long Method problem is to encapsulate each process node into a sub-method. Through effective naming of these sub-methods, we can more easily understand the original complete workflow.

Each sub-method also facilitates unit testing. By ensuring each sub-method functions correctly, the overall behavior remains correct as long as the flow is accurate.

Abstracting a massive function resolves the first issue: maintainability. New team members can grasp the flow faster and continue development.

However, sub-methods alone cannot solve every problem.

For example, in the following example we break down a complex handle_order into a clear, step-by-step process, making it easy for anyone taking over to understand it.

1
2
3
4
5
6
7
8
9
def handle_order(order_data):   
ok = validateOrder(order_data)
if not ok: raise ValidateException()

ok = processPayment(order_data)
if not ok: raise ProcessException()

ok = sendNotification(order_data)
if not ok: raise NotificationException()

Now, we want to add a new process, which is when the order amount exceeds 1000, we need to incorporate an approval step.

1
2
3
4
5
6
7
8
9
10
11
12
13
def handle_order(order_data):   
ok = validateOrder(order_data)
if not ok: raise ValidateException()

ok = processPayment(order_data)
if not ok: raise ProcessException()

if order_data['amount'] > 1000:
ok = approval(order_data)
if not ok: raise ApprovalException()

ok = sendNotification(order_data)
if not ok: raise NotificationException()

Looks fine, right?

My first thought is it looks a bit tricky to write tests for.

Let’s write a classic Python test case to demonstrate the underlying logic behind testing.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@patch('orders.sendNotification')  
@patch('orders.approval')
@patch('orders.processPayment')
@patch('orders.validateOrder')
def test_approval_is_called_when_amount_is_high(self, mock_validate, mock_payment, mock_approval, mock_notification):
# 1. Arrange
mock_validate.return_value = True
mock_payment.return_value = True
mock_approval.return_value = True
mock_notification.return_value = True

order_data = {'amount': 1500, 'user_id': 'U123'}

# 2. Act
handle_order(order_data)

# 3. Assert
mock_validate.assert_called_once()
mock_payment.assert_called_once()
mock_notification.assert_called_once()

# Core assert
mock_approval.assert_called_once()

This test presents several challenges for me.

  1. An overwhelming number of mocks, making them extremely difficult to manage
  2. The use of assert_called_once to validate logic feels counterintuitive
  3. When if-else conditions multiply, such test cases become highly confusing.

Therefore, simply extracting sub-methods is insufficient for achieving a high level of abstraction.

Where’s the problem?

I believe most software development or refactoring actually stops at the previous step. Then layers upon layers get stacked between sub-methods, and eventually spaghetti code emerges.

Because we haven’t achieved true abstraction.

The purpose of abstraction is not to be vague, but to create a new semantic level in which one can be absolutely precise. — Edsger Dijkstra

We only handled the syntax but not the semantics.

If it were me, I would do it this way.

First, we abstract these tasks to be executed into individual tasks.

So we would have an abstract list (A, B, C, D), which is the ultimate expression of abstraction.

1
2
3
4
5
6
commands = {  
'A': validateOrder,
'B': processPayment,
'C': approval,
'D': sendNotification,
}

We’ve transformed the entire handle order process into the following two types.

  1. A -> B -> D
  2. A -> B -> C -> D

This essentially forms a directed acyclic graph (DAG).

Consequently, our subsequent tasks involve DAG operations, where any workflow modification simply adds a node or an edge. Our validation no longer needs to focus on what each node does; we only need to verify that the DAG matches our expectations.

In other words, we are comparing whether assert(workflow == “A -> B -> C -> D”) holds true under specific conditions.

Command Design Pattern

We have now abstracted the process into a DAG, but this is still insufficient.

For each node, its interface is not standardized, so we cannot treat all nodes equally. Therefore, we need to introduce a common interface for these nodes. In design patterns, there is one that fits this scenario perfectly, called Command.

Since these nodes are essentially operations, using Command also satisfies the semantic requirements.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class OrderProcessingReceiver:  
def validate_order(self, context): print("Step A: validate order")
def process_payment(self, context): print("Step B: process payment")
def manual_approval(self, context): print("Step C: approval")
def send_notification(self, context): print("Step D: send notification")

class Command(ABC):
def __init__(self, receiver): self._receiver = receiver

@abstractmethod
def execute(self, context): pass

class ValidateOrderCommand(Command):
def execute(self, context): self._receiver.validate_order(context)

class ProcessPaymentCommand(Command):
def execute(self, context): self._receiver.process_payment(context)

class ManualApprovalCommand(Command):
def execute(self, context): self._receiver.manual_approval(context)

class SendNotificationCommand(Command):
def execute(self, context): self._receiver.send_notification(context)

We consolidated all logic under a single class, OrderProcessingReceiver, to prevent workflow logic from being scattered across multiple locations.

Next, we defined the standard Command interface method execute. With this Command, we can create the operation classes for the four nodes on the DAG, which will later be converted into nodes A, B, C, and D.

DAG Builder

Once we have our operations, we proceed to create the DAG.

First, we need methods to create nodes and edges. In fact, Python 3.9 and later includes a built-in library graphlib that already handles this. We don’t need to reinvent the wheel ourselves.

However, the data structures used by graphlib are a bit ugly, and I’m not particularly comfortable with them.

graph = {  
    'A': set(),  
    'B': {'A'},  
    'C': {'B'},  
    'D': {'C'}  
}

The DAG generated by the graph above would be A -> B -> C -> D. However, you may notice that this definition reverses the order of the DAG, which I personally find counterintuitive. Therefore, I referenced Airflow’s notation and made some modifications.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class DAGBuilder:  
def __init__(self):
self.graph, self._nodes = {}, {}

def task(self, name):
if name not in self._nodes:
self.graph.setdefault(name, set())
self._nodes[name] = self._Node(self, name)
return self._nodes[name]

def build(self):
return self.graph

class _Node:
def __init__(self, builder, name):
self.builder, self.name = builder, name

def __rshift__(self, other):
self.builder.graph[other.name].add(self.name)
return other

Within the node, I overrode __rshift__. This is purely to make the edges in the entire DAG resemble Airflow’s style. Using it is essentially syntactic sugar.

By combining our original example with this DAGBuilder, we can now implement a workflow that generates a DAG.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def create_workflow_blueprint(order_data: dict) -> dict:  
builder = DAGBuilder()
task_a = builder.task('A')
task_b = builder.task('B')
task_c = builder.task('C')
task_d = builder.task('D')

task_a >> task_b
if order_data['amount'] > 1000:
task_b >> task_c
task_c >> task_d
else:
task_b >> task_d

return builder.build()

At this point, we can abstract the entire process into a unified notation. But we need to execute this workflow, right?

So let’s continue.

DAG Runner

We have previously performed numerous abstractions.

  1. Abstracting the workflow into a DAG
  2. Abstracting operations into Commands

Now, we will combine these two to transform this DAG into an executable sequence of Commands.

1
2
3
4
5
6
7
8
9
10
11
12
13
from graphlib import TopologicalSorter  

class DAGRunner:
def __init__(self, graph_definition, commands):
self.ts = TopologicalSorter(graph_definition)
self.commands = commands

def run(self):
context = {}
self.ts.prepare()
for task_name in self.ts.static_order():
self.commands[task_name].execute(context)
return context

This runner doesn’t do anything special. It simply takes the DAG we just generated and our defined commands, feeds them in, and executes them in order using the TopologicalSorter.

The TopologicalSorter correctly extracts the node that should be executed at any given moment based on the DAG’s content, then finds the corresponding operation in the command list and executes it.

Finally, let’s see what handle_order will look like.

1
2
3
4
5
6
7
8
9
10
11
12
13
def handle_order(order_data):  
graph_def = create_workflow_blueprint(order_data)

receiver = OrderProcessingReceiver()
commands = {
'A': ValidateOrderCommand(receiver),
'B': ProcessPaymentCommand(receiver),
'C': ManualApprovalCommand(receiver),
'D': SendNotificationCommand(receiver),
}

runner = DAGRunner(graph_def, commands)
runner.run()

Conclusion

From the final handle_order method, we can see that when abstraction is taken to its extreme, the main program contains no “impurities.” All workflow logic is defined in create_workflow_blueprint, while all operations reside in OrderProcessingReceiver.

When writing tests, unit tests only need to focus on the internal implementation of each Command, ensuring each Command is correct. Integration tests become even simpler, requiring only verification that create_workflow_blueprint produces the expected DAG.

For example.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def test_blueprint_for_high_value_order():  
# Arrange
high_value_order = {'amount': 1500, 'customer_id': 'vip-001'}

expected_blueprint = {
'A': set(),
'B': {'A'},
'C': {'B'},
'D': {'C'}
}

# Act
actual_blueprint = create_workflow_blueprint(high_value_order)

# Assert
assert actual_blueprint == expected_blueprint

Compared to our initial test full of mock objects, this test is straightforward and easy to understand, and it’s also easier to maintain. That’s the power of abstraction.

Besides making the process easier to understand and tests easier to write, what other benefits does this kind of abstraction offer?

Of course it does, and plenty of them.

When we abstract each operation, all operations become equivalent to the DAGRunner. This allows us to add more functionality to the DAGRunner. For example, we can make each operation retry-capable. Without this abstraction layer, adding retry functionality would require modifying every sub-method individually.

Moreover, we can even implement rollback using the Command pattern. If you’re familiar with the Command pattern, you’ll notice that besides the required execute interface, we can also introduce a redo method. This allows DAGRunner to perform a sequential rollback after a Command fails.

Furthermore, we can introduce additional parallel processing. Let’s use the following DAG as an example.

After task A completes, tasks B and C can run concurrently until both finish, at which point task D is processed. This mechanism can also be implemented within DAGRunner.

Since today’s article is already quite large, I can save these follow-up points for the next article if needed.

Let’s call it a day.

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

Boost Microservice Testing with DNS Hijack

Use DNS spoofing to create mock services for easier local integration tests

When developing and testing microservices, we often encounter a common challenge, that is, the functionality of microservices heavily relies on external services. This makes it difficult to conduct functional testing in a closed environment or even a local environment.

Here is a classic example.

This microservice has numerous dependencies, some being internal databases and others external web services. When doing integration testing, especially when running locally, we encounter a serious problem, most tests either fail or become difficult to execute.

There are several common challenging scenarios.

  1. HTTP requests time out because the external service is unavailable, causing subsequent calls to fail.
  2. HTTP requests are processed correctly without timeouts, but return unexpected results, such as a 404 Not Found error.
  3. HTTP requests are received by the corresponding service but cause side effects.

This scenario makes development and testing extremely difficult. Without a real environment to validate the correctness of our own services, the entire correctness is constrained by those external dependencies.

Is there a solution?

Well, we can use DNS Hijack.

DNS Hijack

DNS Hijack is a common cyberattack that redirects our network connections to unexpected destinations, potentially causing harm.

The most classic example occurs when using public Wi-Fi, where the DNS server is hijacked to a malicious DNS. When you visit specific websites, your connection is redirected to phishing sites. This leads you to unknowingly enter your credentials, resulting in credential leakage.

However, DNS Hijack can also be a highly useful tool, particularly during integration testing in development environments, where it significantly boosts development and testing efficiency.

Let’s continue using the example above to illustrate.

For the microservices we’re developing, it remains completely unaware. Everything proceeds as usual, continuing to request services A, B, and C. However, these services have actually been hijacked and redirected to a mock service.

This mock service is highly customizable, capable of consistently returning a 200 OK status or even generating different responses based on the service path and payload.

As a result, microservices can complete various integration tests without being interfered with or even noticing any changes.

How-to

To implement this functionality, two core components are required. One is a DNS server capable of performing spoofing, and the other is a mock service that can handle various requests.

I am not teaching you how to perform DNS hijacking. Please exercise caution when deciding where to use this.

The final solution is available in this GitHub Repo.

Let’s quickly understand this project.

It has three components.

  1. The app plays the role of the original service. It demonstrates a non-intrusive implementation, so the app can be replaced with any service.
  2. dns-proxy is the project’s core component. It redirects all “unknown” hosts to mock-server. The emphasis on “unknown” is crucial because, in microservice integration testing scenarios, we still need microservices to correctly access databases and other essential dependencies.
  3. mock-server is another core component. It responds with a 200 OK to “any” HTTP request. The emphasis on “any” means it can handle requests regardless of the port.

The entire process is straightforward. The app first sends a hostname query to the DNS proxy to obtain the IP address. Since this is merely a proxy, the DNS proxy then queries the actual DNS server for the IP.

If the DNS server recognizes the hostname, it returns the real IP address; otherwise, the query fails. When the DNS proxy fails to obtain the IP, it returns the IP address of the mock server to the app.

Although it’s non-intrusive for the app, we did make a minor modification to it. However, this change is at the infrastructure layer, not at the code level.

The key to achieving DNS hijacking lies in manually specifying the app’s DNS server and configuring it as a DNS proxy. Details can be found in the docker-compose.yml file.

1
2
3
4
5
6
7
8
app:  
image: nginx:alpine # Placeholder - will be replaced with actual application image
container_name: dns-hijack-app
networks:
dns-hijack-net:
ipv4_address: 172.19.0.10
dns:
- 172.19.0.20 # Use DNS proxy as primary DNS server

When using public Wi-Fi, this DNS server is assigned by the DHCP server, which is precisely why public Wi-Fi poses such risks.

The process above details how a DNS proxy operates. Now let’s examine the mock server.

The mock server functions relatively simply, it’s essentially a basic HTTP server that always returns a 200 OK response. Despite this, its core functionality lies in the startup script. Within this script, we use a series of iptables nat rules to redirect any destination port to port 8080, which the mock server is listening on.

This enables the mock server to handle any destination. In other words, when the application initiates requests to external services, whether abc.test:1234 or def.svc:5678, they ultimately reach port 8080 on the mock server.

Integration Details

Since we’re using a DNS proxy without overriding the original DNS server, the network access that was originally available to the app remains completely unaffected.

I tested numerous target combinations in my end-to-end tests, including hosts within the VPN (internal-db.company.example). If you run the e2e tests directly, they will likely fail. The primary reason is that if your network segment cannot access internal-db.company.example, requests will be redirected to the mock server, which is inconsistent with my test assertions.

Therefore, you can modify the e2e validation conditions to better align with your use case.

Additionally, while I mentioned in README.md that swapping the app image allows testing any service, sometimes we already have a complex Docker Compose script where such changes are difficult. That’s okay, as there’s an even simpler approach.

Simply add include to the original microservice’s test script and manually configure dns and networks.

1
2
3
4
5
6
7
8
9
10
11
include:  
- <dep-hijack-mock folder>/docker-compose.yml

services:
orig_svc:
image: orig_image
networks:
dns-hijack-net:
ipv4_address: 172.19.0.25
dns:
- 172.19.0.20

If the service was originally running on a VM or physical machine, it’s straightforward, just edit /etc/resolv.conf. Essentially, you need to specify the DNS server to point to the DNS proxy.

Wrap Up

Although this article explains DNS Hijack, our use case is development and testing within our local environment. Within our controllable scope, redirecting DNS to our required mock server is the fastest and most effective approach.

In this project, it’s evident that the mock server I’m using currently has minimal functionality. It simply responds with a 200 OK to all requests. However, we can expand the mock server’s capabilities to better suit integration testing scenarios.

For example, when the app expects /v1/user/info to return JSON data containing a test user, we can configure the mock server to customize responses based on this path. This depends entirely on what we aim to validate in integration testing and which workflows we wish to replicate.

Any cyberattack is essentially a double-edged sword. On one hand, we need to understand its underlying principles to avoid being compromised. On the other hand, we can leverage its mechanisms to achieve remarkable results.

I hope this project brings some fresh insights.

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

Kiro Workflow for Copilot, Claude & More

Implement spec-driven development for AI agents like Copilot, Claude, and more

Last week we introduced how an agent understands a codebase and built an actual codebase agent to demonstrate the underlying mechanism.

During this process, we highlighted some current limitations of Kiro. For instance, even with a detailed design.md file, Kiro frequently deviates from the plan during execution, producing results that diverge significantly from the intended design.

The root cause lies in Kiro’s execution process, where it uses design.md as context but fails to fully read the entire file. This issue becomes particularly serious when steering content is extensive, as the agent tends to protect its context window. Consequently, it may only read the first few lines of a file and consider it fully processed.

This means when Kiro executes plans, we still need to constantly remind it to deviate from the design. Not only does this waste a lot of vibe request quotas, but it also lowers overall development efficiency. After all, we need to review every detail more carefully.

The problem is that Kiro is a closed-source product, so we can’t fine-tune its prompts. We’re left watching this cycle repeat over and over. So I wondered if there’s a way to use prompts to make tools like Copilot or Claude Code perform Kiro-like workflows while still allowing adjustment of each execution detail.

After actually trying it out, it turns out to work.

Although this project is written in Copilot’s format, it can be applied to any agent, including Claude Code. However, since I’m using Copilot, I’ll demonstrate using its actual workflow.

Installation is straightforward, simply place these prompt files in the project directory or a global prompts folder to use them directly in Copilot’s chat window.

All prompt files are thoroughly explained in the README.md. This article will focus on how to use them.

Copilot Spec-driven Development

At first, just like Kiro’s spec mode, we need to ask Copilot to create a spec directory and convert our requirements into an EARS specification document.

/createSpec I want to build a web service with OAuth authentication, integrating Google OAuth and requiring a database to store user information.

By running the /createSpec command with these requirements, the Agent begins preparing the requirements.md file. If there are materials under .kiro/steering that provide behavioral guidelines for the Agent, it will also reference those contents.

Throughout the requirements creation process, we can continuously interact with the Agent to refine the details of the entire requirements document.

Here’s an interesting observation, I’ve noticed Kiro tends to over-engineer things, adding some obsessive details. So I used system instructions to make our Agent more pragmatic like Linus Torvalds, adhering to the KISS (Keep It Simple, Stupid) principle.

Once we have the requirements.md file, we move on to the design phase.

/design I approve the requirements document. Let’s begin the design phase.

The design phase requires no prompting, as demonstrated by Kiro. We only need to approve requirements.md, after which the Agent will autonomously initiate the design process. Of course, the Agent will still reference steering documents for design guidance, and we can continue to fine-tune the process throughout.

Upon completing the design, the Agent will generate the corresponding design.md file, enabling us to proceed to the next step, the planning phase.

/createTask I approve the design document. Let’s begin planning.

Here, explicit approval is still required; otherwise, the Agent will not proceed.

After this phase is complete, we will have the three most critical specifications: requirements.md, design.md, and tasks.md. Interestingly, these outputs follow Kiro’s rules exactly. Therefore, switching back to Kiro to execute tasks from this stage is no problem at all.

After approving tasks.md, the final step is to enter the execution phase.

/executeTask task1

You can explicitly specify which task to start with, or omit the task number altogether. In that case, the Agent will attempt to begin with the first uncompleted task.

This workflow finishes once the entire tasks.md file is marked as complete. We can see this is basically identical to Kiro’s process.

Wrap Up

In these prompts, I explicitly state that all documents must be thoroughly reviewed before proceeding. However, the preceding steps don’t need to be so meticulous, as humans will collaborate with the Agent to co-create those specification documents.

However, /executeTask operates in headless execution mode, so we absolutely must ensure the Agent understands its purpose. Therefore, within the executeTask prompt, I not only require it to thoroughly review all files but also demand it summarize each one. Only by asking for summaries can we enforce sufficient rigor to ensure the Agent genuinely reads everything carefully.

This insight emerged during my implementation of the codebase agent. The greatest advantage of this workflow is that we can achieve results comparable to Kiro, or even better. Using tools we’re familiar with (and have paid for), we can customize prompts to meet specific needs. Just as I required the agent to thoroughly read files, there’s lots of room for adjustment, allowing us to fully unleash our creativity.

If you have any good ideas, feel free to contribute to my project. Currently, I’ve tailored the prompts based on my own habits and needs, but you might have even more insights to share.

Originally published on Medium

AI Agents for Complex Codebase Analysis

Discover a novel dual-agent method for accurate and deep codebase understanding

Vibe coding has become a major trend lately, with many KOLs claiming they can create and monetize products despite lacking programming skills. Well, I generally respect this approach, since vibe coding excels at greenfield projects, meaning building something from scratch.

As long as the project isn’t overly complex and the requirements are clear, mainstream LLM models can generate decent-quality applications.

However, the real challenge for vibe coding emerges with brownfield projects, where you don’t start from scratch. Instead, you must first establish foundational knowledge and then iterate upon that understanding. This is where vibe coding faces significant difficulties.

This challenge comes from many areas, but the core issue lies in how to comprehend the existing massive codebase.

Therefore, I conducted my own experiments to observe the thought processes of AI agents and attempted to understand their limitations. On one hand, gaining deeper insight into their operations enables more effective utilization. On the other hand, this exploratory journey itself can yield fresh insights.

I’ll try to describe the evolution of this project in a relatively straightforward way, which might also show you how those vibe coding tools work behind the scenes.

The first step, and the most crucial one, is recognition.

In order to let the agent understand the codebase, we first need to know a few things.

  1. Each LLM call is static, so the agent must be able to decide what to do next based on the results of the previous call.
  2. As an LLM is a large language model, we must empower it to use tools — whether through the LLM’s built-in function tooling capabilities or by having the LLM generate plans executed by code.
  3. Since understanding the codebase inevitably requires multiple LLM calls, we must establish strategies, such as where to start and what actions to take. The LLM must also recognize the need to prepare for subsequent rounds.
  4. Multi-round interactions inherently require termination conditions. These conditions must strike a balance between sufficient generalization and maintaining domain expertise. Generalization prevents exhaustive scenario coverage, while expertise ensures reliable outcomes regardless of context.
  5. Most importantly, the context window size is finite. We cannot overload it with excessive information at once.

Step 2: Establish an analyzer

With the above understanding, we can proceed to design this agent.

First, we need an analyzer capable of utilizing tools and sustaining multi-round conversations. Additionally, it must incorporate a self-assessment mechanism.

Thus, an agent capable of invoking tools and conducting multi-round conversations is complete.

But this raises a problem: when an agent reviews its own generated reports, might it fall into a state of self-satisfaction? Perhaps it finds the report perfectly satisfactory, yet others actually consider it worthless.

Yes, absolutely.

There are several key reasons.

  1. Simply teaching an agent to use tools is already difficult.
  2. Beyond tool usage, teaching an agent to analyze strategies is even more challenging.
  3. It also requires informing the agent of a reasonable stopping condition.

From the above description, we can see that an agent is tasked with too many responsibilities, leaving it unable to ensure report quality. After all, the quality of a report depends on numerous factors, not merely reviewing the codebase once, but also systematically gathering and organizing the necessary information related to user queries.

Simply put, understanding user intent itself requires a dedicated professional to handle.

Step 3: Establish a gatekeeper

Therefore, we need an external authority to oversee the quality of the entire output.

This authority doesn’t need to use tools or even look at the code. Their role is simply to act as a demanding and nitpicky senior engineer aka a specialist. They possess the expertise of a professional engineer, so they know what’s essential and what’s meaningless.

Through this specialist, we can validate the analyzer’s output, and the specialist will also guide the analyzer on how to proceed more effectively. In other words, the specialist must understand user intent and enrich user queries.

This dual-agent design offers significant scalability because both agents have a single, focused responsibility, resulting in a single, unified interface.

  • Analyzer’s interface involves receiving a query and attempting to locate corresponding information across the entire codebase to fulfill that query.
  • Specialist’s interface involves receiving a query and a report, then defining the report’s value.

Now, by simply combining these two agents, we can accomplish the challenging task of understanding the codebase.

Project Architecture

The entire project architecture consists of only three core components.

  • Code Analyzer: This is the primary agent responsible for executing analysis tasks. It adeptly invokes various shell commands to accomplish its duties. Most importantly, it possesses the ability to continuously explore. It begins by establishing broad strategies and gradually drills down to the core of the problem, relying on its own iterative process.
  • Task Specialist: Where there’s somebody working, there must be somebody verifying. Specialist is responsible for validating the analyzer’s results. If the analysis quality is insufficient, it will be rejected and sent back for rework.
  • Agent Manager: This role coordinates the entire analyzer and specialist system. It guides the analyzer through its tasks and passes results to the specialist. However, it also enforces termination conditions to prevent the specialist from operating indefinitely.

The entire architecture’s workflow is illustrated in the following flowchart.

From this diagram, we can observe two distinct cycles.

The first is the analyzer’s self-iteration process. It initiates multiple rounds of conversations, with each subsequent round building upon the previous one to explore unknown areas. This continues until the analyzer’s self-checking mechanism determines the results meet the criteria. Since these criteria are not overly stringent, the analyzer operates autonomously.

The second review, however, is significantly more rigorous, as a specialist engineer examines the report’s quality in detail. If the report is poor, it gets sent back for rewriting, but the specialist will specify what additional content is needed.

This process ensures the final report is perfect. Right?

Prompt Engineering

All the concepts above are correct, and the workflow is fine. But even for such a single-purpose agent, I spent a lot of time tweaking their prompts to make them look like ordinary people.

Yes, ordinary people — far from professionals.

Let me break down the system prompts for this project to understand their respective requirements.

Code Analyzer Prompts

The Analyzer prompt has several key points.

  1. Key findings: This is the core mechanism for multi-round self-iteration. Each round collaboratively maintains this knowledge base, ensuring the process stays on track throughout iteration.
  2. Teach the analyzer the multi-round iteration process and how to pass information between rounds.
  3. The iteration strategy should start with broad classification of project type and language, then identify corresponding classes, methods, and imports for each language.
  4. Tool usage requires clear guidance: specify what tools are available, what tools are unavailable, and how to use them.
  5. One crucial point I observed over time: explicitly instruct it on handling varying file sizes. For large files, implement chunk-based reading.
  6. Finally, clearly define the expected output format and the desired results.

Points 3 and 5 above are, in my opinion, the most easily overlooked details.

The third point involves enhancing the analyzer’s generic capabilities. It needs to be able to adapt while handling various languages, so we can only achieve this by providing fundamental principles as guidance. Here, we deliberately used more general terms instead of Python’s def or Node.js’s function.

The fifth point emerged during its iterative development, where I observed it frequently treating only the first few lines of a file as fully read. This is due to the LLM’s self-imposed limitations. Constrained by window size, the LLM subconsciously takes shortcuts.

When using Kiro, you might have noticed that even with design.md, it still occasionally makes mistakes. That’s because it doesn’t read the entire file. Other tools like Cursor or Copilot exhibit the same behavior.

Therefore, when prompting these vibe coding tools, I deliberately emphasize that they must read the entire file and summarize its key points afterward. This is the most effective way to prevent them from cutting corners.

Task Specialist Prompts

Compared to the analyzer, the specialist’s system prompt is much simpler, requiring only telling it the key points to review.

  1. Establish a strict persona for them, focused on details and resistant to persuasion.
  2. At the same time, inform them of the content required in the report, including various details.
  3. Provide examples to guide their judgment.

Although the core concept is simple, teaching an LLM to refuse requests is truly challenging.

You may have noticed that most models tend to agree rather than disagree due to their training processes. Consequently, every LLM try to be agreeable, leading to many obviously poor-quality reports being allowed to pass.

Even though it only needs to focus on refusal, refusal is exactly the psychological barrier that is the hardest to overcome.

Conclusion

The operational flow of this project has been thoroughly explained above. Through analyzing the entire architecture, I believe everyone can gain a deeper understanding of the underlying principles behind vibe coding. I believe most tools incorporate such a deep iterative process, though each offers distinct customizable prompts and tool sets.

After implementing this project, I finally understood why even a spec-driven development framework like Kiro still fails to solve the problem of agents ignoring designs. The underlying truth is surprisingly simple, and it boils down to the files not being read in their entirety.

I’ll include a sequence diagram for this project at the end of the article, offering another perspective on how agents comprehend the codebase.

Through this hands-on process, I refined a set of prompts capable of transforming any vibe coding tool into a spec-driven development like Kiro. Today’s story ran a bit long, so let’s continue next time with the series of prompts I distilled. I’ll show you how to enable Copilot to define requirements, design architectures, establish plans, and execute them step by step.

Let’s call it a day.

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

0%