AI can generate code, explain unfamiliar concepts, and help us build software faster. But as these tools become part of everyday development, I've been thinking about a more important question:
If AI can generate code, what should we focus on learning as software engineers?
A 30-minute office-hours conversation with Matthew Titmus, author of Cloud Native Go, gave me an opportunity to reflect on that question. It shifted my attention from which technology to learn next toward the judgment needed to build reliable, maintainable software.
Writing Code Is Only Part of the Job
It is tempting to measure progress by how quickly we can implement a feature. AI makes that especially easy: describe a requirement, receive an implementation, make a few changes, and move on.
But code that looks correct in a small example may behave very differently in production.
A solution can pass a happy-path test and still fail under concurrency, network interruptions, unexpected input, or partial failures. It can work today while creating problems for the next engineer who has to maintain it.
The important questions go beyond whether the code runs:
- Does it handle failure cases?
- What assumptions does it make?
- Can it be tested and maintained?
- What happens when a dependency is unavailable?
- Does it behave correctly under load?
- Can someone else understand why it was designed this way?
These are engineering questions, not just coding questions. AI can help us investigate them, but we still need enough understanding to ask the right questions and evaluate the answers.
Think About What Happens When Systems Fail
Consider a simple payment-processing system written in Go.
A worker receives a payment job from a queue and sends a request to a payment provider. The provider processes the payment, but the worker crashes before marking the job as complete. When the queue delivers the job again, another worker may attempt to process the same payment.
The code might be short. The engineering problem is not.
A reliable design needs to consider questions such as:
- How do we prevent the same payment from being processed twice?
- Should the operation use an idempotency key?
- How should retries and timeouts work?
- What happens when the payment provider succeeds but our service never receives the response?
- How do we reconcile a payment whose final status is unknown?
- How do we make the job's state observable and recoverable?
The exact solution depends on the system and the guarantees provided by its dependencies. A queue alone does not make payment processing reliable.
This is why understanding concepts such as idempotency, retries, timeouts, state transitions, and reconciliation matters. They help us reason about behavior that is not obvious from reading a single function.
AI can suggest an implementation for a worker. Developing the judgment to decide whether that implementation is safe requires understanding the system around it.
Write Code—and Read Code
Writing code teaches us how to turn an idea into an implementation. Reading code teaches us how other people have approached problems, organized systems, and handled constraints.
Both are important.
Reading a real codebase exposes us to decisions that tutorials often simplify: package boundaries, error handling, naming, tests, configuration, compatibility, and the trade-offs behind an implementation. It also helps us recognize patterns that are difficult to learn from isolated examples.
Open-source projects are especially useful for this. We can follow a request through multiple layers, inspect how errors are handled, read tests to understand expected behavior, and compare the design with our own assumptions.
We do not have to understand an entire repository at once. Start with one feature or question. Trace the relevant code, read its tests, and explore the decisions around it.
The goal is not simply to read more code. It is to become better at understanding why code is written the way it is.
Learn to Recognize Problematic Code
Being able to write a working implementation is valuable. Being able to notice when an implementation is fragile is another skill.
Sometimes the problem is obvious: duplicated logic, unclear names, or a function doing too many things. Other times, it takes deeper reasoning to spot risks such as unsafe retries, shared mutable state, missing cancellation, or an operation that can leave a system in an inconsistent state.
Consider a concurrent worker pool in Go. The initial implementation may process jobs successfully, but what happens when the application shuts down? Do workers stop when their context is canceled? Can goroutines remain blocked? Are errors propagated correctly? Is access to shared state safe? Is the amount of concurrency bounded?
These questions encourage us to look beyond the happy path and examine the behavior of the whole implementation.
AI-generated code deserves the same scrutiny as code written by a teammate—or by ourselves. Review it, run it, test edge cases, inspect its assumptions, and check whether the design fits the system. A confident explanation is not proof that an implementation is correct.
Understand the System, Not Just the Function
Software rarely exists in isolation. A seemingly small change can affect databases, queues, APIs, background workers, caches, and external services.
That means good engineering requires a view of the relationships between components and the ways they can fail.
For example, adding a retry may improve resilience when a request fails temporarily. But retries can also amplify load, duplicate side effects, or make an outage worse if they are not bounded and designed carefully. Adding concurrency may increase throughput, but it can also increase contention or overload a dependency.
There are trade-offs behind many engineering decisions. The right choice depends on the requirements, constraints, failure modes, and operational needs of the system.
Learning to reason about those trade-offs is a gradual process. It comes from studying real systems, debugging unexpected behavior, reading design discussions, testing assumptions, and reflecting on what worked and what did not.
Use AI Without Outsourcing Your Thinking
I do not see AI as something software engineers should avoid. Used well, it can be a powerful learning and development tool.
It can help explain an unfamiliar part of a codebase, compare possible approaches, generate test cases, or provide a starting point for an implementation. It can also help us explore topics that would otherwise take longer to investigate.
The important part is how we use it.
Instead of accepting the first answer, we can ask why an approach works, what alternatives exist, and what trade-offs each option introduces. We can ask for failure scenarios, write tests to challenge the implementation, and verify important details against documentation or the behavior of the system itself.
When AI generates a concurrent Go worker pool, for example, I should not stop at whether it compiles. I should understand how cancellation works, whether goroutines can leak, how errors are handled, and what happens during shutdown.
The goal is not to prove that we can do everything without AI. It is to make sure we are learning while using it—and that we can still evaluate the results.
What I Want to Prioritize
As I continue growing as a software engineer, I want to be more intentional about building these skills.
That means spending time reading production code and open-source projects, not only writing new code. It means asking more questions about failure cases and system behavior. It means testing AI-generated implementations rather than assuming they are correct, and learning the reasoning behind solutions instead of memorizing patterns.
I also want to get better at explaining the trade-offs behind my own decisions. Being able to describe not only what I built, but why I built it that way, is an important part of becoming a stronger engineer.
These are not skills that can be mastered in a single project or conversation. They develop through practice, curiosity, feedback, and experience.
Final Thoughts
My conversation with Matthew Titmus made me think more carefully about what I want to prioritize as I grow as a software engineer.
AI will continue to change how software is developed. I want to use it to learn faster and explore more ideas without losing the ability to question, evaluate, and improve the solutions it produces.
Generating code is becoming easier. Developing the judgment to build reliable software takes practice, curiosity, and experience.
I'm grateful to Matthew Titmus for sharing his time and perspective.
How are you balancing writing code, reading code, and using AI in your everyday work?
I'd love to hear what has helped you become a better engineer.
Top comments (2)
The engineering habit worth preserving is turning an AI suggestion into a falsifiable claim: name the invariant, write the smallest test or observation that could disprove it, then decide. That keeps tool fluency from replacing problem framing.
Exactly. Just because AI suggests it doesn't mean it'll work.