At some point, ChatGPT stopped feeling like a tool and started feeling like a reflex.
Need a parser? Ask ChatGPT.
Strange exception in a background worker? Ask ChatGPT.
Forgot how a lock behaves under contention? Ask ChatGPT.
Need to rename a method? Apparently, that also required artificial intelligence.
Nothing looked wrong on the surface. Tasks were moving. Pull requests were getting merged. The code usually worked. Sometimes it even looked cleaner than what I would have written from scratch.
But there was a small problem.
A few days later, I often could not explain why a certain solution was built that way. I remembered the task, the final code, and maybe the prompt. The reasoning in between was missing.
That bothered me more than I expected.
So I set a simple rule: for six months, no ChatGPT, no Copilot Chat, no AI-generated code pasted into production. Documentation, source code, issue trackers, books, debuggers, profilers, and search engines were allowed. AI assistants were not.
The experiment started as a way to test my own dependence. It ended up changing how I design APIs, debug systems, read unfamiliar code, and even write comments.
The first week was embarrassingly slow
The first few days were not dramatic. There was no sudden collapse in productivity. I did not forget how loops worked, and I was still able to create a REST endpoint without emotional support.
The slowdown appeared in small places.
I spent more time naming things. I checked function signatures instead of generating them. I read package source code. I opened debugger windows I had ignored for months. A task that would normally take forty minutes sometimes took twice as long.
The worst part was not the extra time. It was the feeling that I had become less capable than I remembered.
One example was a retry mechanism for an HTTP client. With AI, I would probably describe the requirements and ask for a robust implementation with exponential backoff, jitter, cancellation, and error classification. Without it, I had to decide what robust actually meant.
That forced several questions:
Should every error be retried?
Should the server response be trusted?
What happens when the request body cannot be replayed?
How does cancellation propagate?
Can two retry loops accidentally multiply the load?
The final code was not especially beautiful, but every line had a reason.
package resilienthttpimport ("bytes""context""errors""fmt""io""math""math/rand""net/http""strconv""time")type RetryPolicy struct {MaxAttempts intBaseDelay time.DurationMaxDelay time.Duration}type Client struct {httpClient *http.Clientpolicy RetryPolicyrng *rand.Rand}func NewClient(httpClient *http.Client, policy RetryPolicy) *Client {if httpClient == nil {httpClient = http.DefaultClient}if policy.MaxAttempts < 1 {policy.MaxAttempts = 1}if policy.BaseDelay <= 0 {policy.BaseDelay = 100 * time.Millisecond}if policy.MaxDelay < policy.BaseDelay {policy.MaxDelay = 5 * time.Second}return &Client{httpClient: httpClient,policy: policy,rng: rand.New(rand.NewSource(time.Now().UnixNano())),}}func (c *Client) Do(ctx context.Context,method string,url string,body []byte,headers http.Header,) (*http.Response, error) {var lastErr errorfor attempt := 1; attempt <= c.policy.MaxAttempts; attempt++ {req, err := http.NewRequestWithContext(ctx,method,url,bytes.NewReader(body),)if err != nil {return nil, fmt.Errorf("create request: %w", err)}for key, values := range headers {for _, value := range values {req.Header.Add(key, value)}}resp, err := c.httpClient.Do(req)if err == nil && !shouldRetryStatus(resp.StatusCode) {return resp, nil}if err != nil {lastErr = err} else {lastErr = fmt.Errorf("unexpected status code: %d", resp.StatusCode)_, _ = io.Copy(io.Discard, resp.Body)_ = resp.Body.Close()}if attempt == c.policy.MaxAttempts {break}delay := c.retryDelay(attempt, resp)timer := time.NewTimer(delay)select {case <-ctx.Done():timer.Stop()return nil, errors.Join(lastErr,fmt.Errorf("request cancelled: %w", ctx.Err()),)case <-timer.C:}}return nil, fmt.Errorf("request failed after %d attempts: %w",c.policy.MaxAttempts,lastErr,)}func shouldRetryStatus(status int) bool {switch status {case http.StatusRequestTimeout,http.StatusTooManyRequests,http.StatusBadGateway,http.StatusServiceUnavailable,http.StatusGatewayTimeout:return truedefault:return status >= 500}}func (c *Client) retryDelay(attempt int,resp *http.Response,) time.Duration {if resp != nil {if retryAfter := parseRetryAfter(resp.Header.Get("Retry-After")); retryAfter > 0 {return minDuration(retryAfter, c.policy.MaxDelay)}}exponential := float64(c.policy.BaseDelay) *math.Pow(2, float64(attempt-1))capped := math.Min(exponential,float64(c.policy.MaxDelay),)// Full jitter prevents synchronized retry storms// when many clients fail at the same time.return time.Duration(c.rng.Float64() * capped)}func parseRetryAfter(value string) time.Duration {if value == "" {return 0}seconds, err := strconv.Atoi(value)if err == nil && seconds > 0 {return time.Duration(seconds) * time.Second}when, err := http.ParseTime(value)if err != nil {return 0}delay := time.Until(when)if delay < 0 {return 0}return delay}func minDuration(a, b time.Duration) time.Duration {if a < b {return a}return b}
Before this experiment, I might have accepted a shorter version because it looked complete. Writing it manually made me notice the ugly edge cases: replayable bodies, resource cleanup, duplicate retries, timer leaks, and synchronized clients hammering a sick service.
The code took longer. The design took longer. But the understanding stayed.
Debugging changed more than coding
The biggest surprise was not that I wrote code differently. It was that I started debugging differently.
Before the experiment, I often treated errors as text. Copy the stack trace, add context, ask for likely causes, try the most plausible suggestion. This works often enough to become dangerous.
Without AI, an error stopped being a question and became evidence.
I began collecting facts before changing anything:
-
exact input;
-
state before failure;
-
state after failure;
-
timing;
-
number of concurrent operations;
-
resource ownership;
-
whether the bug was reproducible;
-
whether the reproduction depended on ordering.
One bug involved a Python service that occasionally returned stale data after an update. The database transaction was correct. Cache invalidation looked correct. Tests passed.
The real issue was in an in-process asynchronous cache refresh. Multiple requests could trigger refreshes at nearly the same time. An older refresh sometimes completed after a newer one and overwrote fresh data with an older snapshot.
The bug was not in the cache library. It was in the absence of a monotonic version check.
from __future__ import annotationsimport asynciofrom dataclasses import dataclassfrom datetime import datetime, timezonefrom typing import Awaitable, Callable, Generic, TypeVarT = TypeVar("T")@dataclass(frozen=True)class VersionedValue(Generic[T]): version: int value: T loaded_at: datetimeclass AsyncVersionedCache(Generic[T]): def __init__( self, loader: Callable[[], Awaitable[tuple[int, T]]], ) -> None: self._loader = loader self._value: VersionedValue[T] | None = None self._lock = asyncio.Lock() self._refresh_task: asyncio.Task[None] | None = None async def get(self) -> T: current = self._value if current is None: await self.refresh() current = self._value if current is None: raise RuntimeError("cache failed to initialize") return current.value async def refresh(self) -> None: version, value = await self._loader() candidate = VersionedValue( version=version, value=value, loaded_at=datetime.now(timezone.utc), ) async with self._lock: current = self._value if current is not None and candidate.version < current.version: return self._value = candidate def refresh_in_background(self) -> None: if self._refresh_task is not None and not self._refresh_task.done(): return self._refresh_task = asyncio.create_task( self._run_background_refresh() ) async def _run_background_refresh(self) -> None: try: await self.refresh() except asyncio.CancelledError: raise except Exception as exc: # In production this would be structured logging. print( "cache refresh failed", { "error_type": type(exc).__name__, "error": str(exc), }, )# Example loader with deliberately reordered completion.class FakeRepository: def __init__(self) -> None: self._responses = [ (11, {"feature_enabled": True}, 0.050), (10, {"feature_enabled": False}, 0.150), ] self._index = 0 async def load(self) -> tuple[int, dict[str, bool]]: version, payload, delay = self._responses[self._index] self._index += 1 await asyncio.sleep(delay) return version, payloadasync def main() -> None: repository = FakeRepository() cache = AsyncVersionedCache(repository.load) first = asyncio.create_task(cache.refresh()) second = asyncio.create_task(cache.refresh()) await asyncio.gather(first, second) value = await cache.get() assert value["feature_enabled"] is True print(value)if __name__ == "__main__": asyncio.run(main())
What would you blame first in that situation?
The event loop? The cache? The database isolation level? Some mysterious network delay?
The answer only became obvious after drawing the timeline by hand.
That was one of the main lessons of the experiment: debugging is not asking for explanations. Debugging is reducing the number of possible explanations until only one remains.
I started reading source code again
Documentation tells you what a library promises. Source code tells you what it actually does when things become weird.
I had slowly stopped opening dependency code because an assistant could usually summarize behavior faster. The summary was often accurate, but it removed something useful: the small implementation details that change design decisions.
For example, I was working on a TypeScript queue consumer. The SDK automatically renewed message locks, which sounded convenient. The documentation explained the feature, but the source revealed the renewal was tied to an internal timer and stopped under specific failure paths.
That changed the architecture. Instead of assuming processing could run forever, the worker began using explicit deadlines, idempotency keys, and resumable steps.
The example below is simplified, but the structure is close to what eventually worked.
import { randomUUID } from "node:crypto";type JobPayload = { accountId: string; reportDate: string;};type JobMessage = { id: string; payload: JobPayload; deliveryAttempt: number;};type StepName = | "LOAD_DATA" | "BUILD_REPORT" | "STORE_REPORT" | "SEND_NOTIFICATION";type JobState = { jobId: string; completedSteps: Set<StepName>; reportLocation?: string;};interface StateRepository { load(jobId: string): Promise<JobState | null>; save(state: JobState): Promise<void>;}interface ReportService { loadData(payload: JobPayload): Promise<unknown>; buildReport(data: unknown): Promise<Buffer>; storeReport( jobId: string, report: Buffer ): Promise<string>; sendNotification( payload: JobPayload, reportLocation: string ): Promise<void>;}class DeadlineExceededError extends Error {}class Deadline { private readonly startedAt = Date.now(); constructor(private readonly timeoutMs: number) {} remainingMs(): number { const elapsed = Date.now() - this.startedAt; return Math.max(0, this.timeoutMs - elapsed); } assertAvailable(minimumMs: number): void { if (this.remainingMs() < minimumMs) { throw new DeadlineExceededError( `Not enough time left: ${this.remainingMs()} ms` ); } } async run<T>( operation: () => Promise<T>, reserveMs = 250 ): Promise<T> { this.assertAvailable(reserveMs); const timeoutMs = this.remainingMs() - reserveMs; return await Promise.race([ operation(), new Promise<never>((_, reject) => { const timer = setTimeout(() => { reject( new DeadlineExceededError( `Operation exceeded remaining deadline` ) ); }, timeoutMs); timer.unref?.(); }), ]); }}class JobProcessor { constructor( private readonly states: StateRepository, private readonly reports: ReportService ) {} async process( message: JobMessage, timeoutMs: number ): Promise<void> { const deadline = new Deadline(timeoutMs); const state = (await this.states.load(message.id)) ?? { jobId: message.id, completedSteps: new Set<StepName>(), }; let sourceData: unknown; let reportBuffer: Buffer; if (!state.completedSteps.has("LOAD_DATA")) { sourceData = await deadline.run(() => this.reports.loadData(message.payload) ); state.completedSteps.add("LOAD_DATA"); await this.states.save(state); } else { sourceData = await deadline.run(() => this.reports.loadData(message.payload) ); } if (!state.completedSteps.has("BUILD_REPORT")) { reportBuffer = await deadline.run(() => this.reports.buildReport(sourceData) ); state.completedSteps.add("BUILD_REPORT"); await this.states.save(state); } else { reportBuffer = await deadline.run(() => this.reports.buildReport(sourceData) ); } if (!state.completedSteps.has("STORE_REPORT")) { const location = await deadline.run(() => this.reports.storeReport( state.jobId, reportBuffer ) ); state.reportLocation = location; state.completedSteps.add("STORE_REPORT"); await this.states.save(state); } if (!state.reportLocation) { throw new Error( "State is inconsistent: report location is missing" ); } if (!state.completedSteps.has("SEND_NOTIFICATION")) { await deadline.run(() => this.reports.sendNotification( message.payload, state.reportLocation as string ) ); state.completedSteps.add("SEND_NOTIFICATION"); await this.states.save(state); } }}class InMemoryStateRepository implements StateRepository { private readonly storage = new Map<string, JobState>(); async load(jobId: string): Promise<JobState | null> { const state = this.storage.get(jobId); if (!state) { return null; } return { ...state, completedSteps: new Set(state.completedSteps), }; } async save(state: JobState): Promise<void> { this.storage.set(state.jobId, { ...state, completedSteps: new Set(state.completedSteps), }); }}const message: JobMessage = { id: randomUUID(), payload: { accountId: "account-42", reportDate: "2026-07-31", }, deliveryAttempt: 1,};
This code is longer than a naive message handler. It also survives retries, duplicate delivery, partial completion, and short lock durations.
The useful part was not memorizing how the SDK worked. It was learning to distrust invisible guarantees.
A library can renew a lock. A database can retry a transaction. A framework can catch an exception. But unless you know where the guarantee begins and ends, you are designing around a sentence instead of a system.
My code became less clever
This part surprised me.
Without AI-generated suggestions, the code became more boring.
There were fewer abstractions created on the first attempt. Fewer generic helpers. Fewer classes with names like UniversalExecutionContextFactory. Fewer patterns introduced because they looked professional.
I started writing the simplest version that exposed the real constraints.
Then I waited.
If two modules needed the same behavior, I compared them. If the behavior was truly identical, I extracted it. If it was only superficially similar, I left it alone.
This reduced what I call abstraction debt.
Technical debt usually means code that is hard to change. Abstraction debt is worse in a different way: the code looks reusable, so everyone is afraid to remove it, but every new feature adds another condition.
A helper that originally handled HTTP requests starts receiving options for caching, retries, tracing, authentication, pagination, streaming, and circuit breaking. Eventually it becomes a framework nobody planned to build.
During the experiment, I deleted several abstractions and replaced them with duplicated code. That sounds wrong until you look at the result.
Two small functions that are easy to understand can be cheaper than one generic mechanism with twelve configuration flags.
Have you ever opened a utility and needed to inspect four call sites before understanding what the utility actually does? That is usually not reuse. That is compressed confusion.
Tests stopped being decoration
AI-generated tests often look convincing. They have clear names, mocks, assertions, and nice coverage numbers.
The problem is that they frequently test the shape of the implementation instead of the behavior of the system.
During the six months, I started asking a different question before writing a test:
What failure am I trying to make impossible?
This changed the test structure.
Instead of asserting that a repository method was called exactly once, I tested whether duplicate messages produced duplicate side effects.
Instead of checking that a retry function slept three times, I tested whether it stopped when the deadline expired.
Instead of mocking the database everywhere, I ran more integration tests against a real disposable database.
The test suite became slower, but it also became useful. A broken test usually pointed to an actual contract.
One particularly helpful practice was writing the failure story before writing the test:
-
two workers receive the same job;
-
both read an unprocessed state;
-
both create the same invoice;
-
the customer gets charged twice.
That story is much more valuable than a test called should call payment service once.
It also made reviews easier. A reviewer could understand why the test existed without reverse-engineering the mock setup.
I learned to separate recall from reasoning
One reason AI feels so powerful is that programming contains a lot of recall.
What is the syntax for a window function?
How does an HTTP conditional request work?
What flag enables race detection?
Which Redis command has the required atomic behavior?
Using a tool for recall is not a problem. The problem begins when recall and reasoning are mixed together.
A model can produce the syntax and quietly make the architectural decision at the same time.
For example, you ask how to implement distributed locking in Redis. The response may give you a complete solution. But the more important question is whether a distributed lock is the right primitive at all.
Maybe the operation should be idempotent.
Maybe ownership should be represented in the database.
Maybe optimistic concurrency is enough.
Maybe the task queue already provides the required guarantee.
Maybe the lock only hides a broken state model.
After six months, I started splitting questions in two.
First: what facts do I need?
Second: what decision belongs to me?
Today, I am comfortable asking AI for syntax, API comparisons, documentation summaries, or a list of edge cases. I am much less comfortable asking it to choose the architecture before I have written down the constraints.
That small distinction changed everything.
The productivity numbers were not what I expected
I tracked rough task duration before and during the experiment. The data was not scientific, and the sample size was limited, so I would not present it as a universal result.
Still, the pattern was clear.
Small isolated tasks became slower. Writing serializers, command-line parsing, test fixtures, migrations, and ordinary CRUD code took more time.
Complex debugging became faster after the first two months.
Code review became faster because I understood my own changes better.
Rework dropped.
The largest improvement appeared in tasks that crossed several boundaries: application code, database behavior, queue semantics, caching, and deployment configuration.
Before the experiment, I could reach a plausible solution quickly. After the experiment, reaching the first solution took longer, but fewer solutions had to be replaced later.
That difference is hard to notice when productivity is measured by commits or tickets closed.
Fast code generation looks productive immediately. Better reasoning often becomes visible weeks later, when the system receives strange traffic, a dependency times out, or a deployment is rolled back.
The rule I use now
I use AI tools again.
The experiment did not turn me into someone who thinks developers should write everything manually. That would be a strange conclusion. We use compilers, frameworks, linters, debuggers, code generators, and libraries because our time is limited.
But I no longer open an assistant at the first sign of friction.
My current rule is simple:
I do not ask AI to solve a problem I have not yet described to myself.
Before opening a chat, I write down:
-
the observed behavior;
-
the expected behavior;
-
the relevant constraints;
-
the boundaries of the system;
-
what I already tried;
-
what would count as a correct result.
Sometimes the answer becomes obvious during that process, and the assistant is no longer needed.
Sometimes I still use it, but the conversation is much better because the problem is no longer vague.
I also avoid pasting generated code directly into the project. I rewrite it, rename things, remove unnecessary layers, and verify the behavior against documentation or source code.
The goal is not to prove that I can code without help. The goal is to keep ownership of the reasoning.
What actually came back after six months
Several skills returned quietly.
I became better at reading error messages without immediately searching for them.
I started using debuggers more often and print statements less often.
I could keep more of the system model in my head.
I noticed missing invariants earlier.
I became more suspicious of code that looked complete too quickly.
Most importantly, I stopped confusing code production with software development.
Producing code is one part of the job. The harder part is deciding what must be true, what can fail, who owns state, where timeouts live, and how the system behaves when the happy path disappears.
AI is excellent at helping with code production. It can also help with reasoning, but only when the developer remains active enough to challenge the answer.
Six months without ChatGPT did not teach me that AI is bad.
It taught me that convenience can hide skill loss, and skill loss is difficult to notice while everything still compiles.
Would I repeat the experiment for another six months?
Probably not.
But I would repeat the first two weeks every year.
That was the uncomfortable part. It was also the most useful.
ссылка на оригинал статьи https://habr.com/ru/articles/1065122/