There was a time when I judged my progress as a developer mostly by what I could make the code do. If a page rendered correctly, an API returned the expected response, or a background task completed without an error, the work felt finished.
Working on real internal applications during my internship at PT Surabaya Autocomp Indonesia changed that definition. The code still mattered, but it became only one part of a much larger chain: understanding a problem, translating it into a workable flow, building the system, validating it with users, deploying it, and then living with the consequences of that deployment.
That experience made software feel less like a collection of features and more like a sequence of decisions. A decision about what to build. A decision about what not to build. A decision about where logic belongs. A decision about whether the system is simple enough for another developer to maintain. A decision about whether a user can actually understand it without knowing anything about the technologies behind it.
A request is not yet a problem
One of the most important shifts in my thinking was learning not to treat a user's first request as a complete specification.
A user can describe what they want based on the workflow they already know. That does not necessarily mean the requested feature is the best representation of the problem. Before thinking about Django, Laravel, PostgreSQL, or any other tool, I have to understand what is happening around the request.
What are they doing now? Where does the process slow down? Which information do they repeatedly enter? What part is confusing? What happens when the normal flow fails? Who needs the result afterward?
Those questions sound less technical than choosing a framework, but they can have a greater effect on whether the software succeeds. If I misunderstand the workflow, good code can simply automate the wrong thing.
Interviewing users and observing how they work made this very visible. A screen can be logically clean from a developer's perspective while still being awkward for the person who has to use it every day. A data model can be elegant while failing to represent the exceptions that exist in a real business process. A feature can satisfy the literal request while adding more steps to the actual job.
For me, this is where software engineering begins: not with implementation, but with reducing ambiguity.
Workflows before frameworks
As a student, it is easy to become attached to technology choices. New tools are concrete. They are easy to compare, learn, and put into a project stack. Real problems are less neat.
My internship work involved technologies such as Django, Laravel, PostgreSQL, Celery, Gunicorn, Nginx, and SSH-based deployment. Each of them solved a particular kind of problem, but none of them could decide what the application should be.
That distinction sounds obvious, yet it is easy to reverse the order in practice. I have caught myself thinking in terms such as, "This would be a good place to use a worker," or, "This could be separated into another service," before proving that the problem needed those things.
Now I try to start with the flow instead. What enters the system? What must happen synchronously? What can happen later? What must be stored? What can fail independently? Which people interact with each step?
Only after that does the architecture become meaningful.
Celery, for example, can be useful when work should not block a request. PostgreSQL can provide strong relational structure for business data. Nginx and Gunicorn are part of making a Django application available reliably rather than merely running it in a development server. But the value of those tools comes from the needs they answer, not from the fact that they make the stack look more complete.
A prototype is part of the conversation
Prototyping also changed meaning for me. I used to think of a prototype mainly as an early version of the interface. In practice, it is also a way to test whether two people have the same understanding of a process.
A written explanation leaves room for different interpretations. A visible flow makes disagreements easier to find. A user can point at a step and say that it should happen earlier, that a field is unnecessary, or that a case I thought was unusual is actually common.
That kind of feedback is cheaper before the implementation becomes deeply connected to the rest of the system.
The point is not to avoid change. Software changes. The point is to make important misunderstandings visible while they are still inexpensive to correct.
Production changes the meaning of "done"
The strongest difference between coursework and real software appeared after implementation.
On a local machine, I control almost everything. I know which environment variables exist. I know which process I started. I can reset data, inspect logs directly, and retry without affecting anyone else.
Deployment removes that comfort.
A Django application behind Gunicorn and Nginx has more boundaries than the application code itself. The environment must be correct. Static files, permissions, reverse proxy configuration, processes, database access, and networking all become part of the behavior users experience. If deployment happens through SSH, the steps taken on the server are part of the engineering process too.
The same is true after release. Maintenance is not an optional final chapter. Logs have to be interpretable. Failures need to be diagnosable. A future change should not require rediscovering every hidden assumption in the system.
This is why I no longer think of deployment as the moment after development. Deployment is one of the conditions the software must be designed to survive.
Existing systems teach restraint
Greenfield projects are attractive because the architecture can begin from a clean page. Existing systems do not offer that freedom.
When an application has to work with an existing database, existing workflows, or other applications that are already in use, every change has a context. A table may look poorly named but be consumed by another system. A process may look redundant but exist because of an operational requirement. A seemingly cleaner redesign may create migration risk that is not justified by the benefit.
That environment taught me to ask a different question. Instead of asking, "How would I design this from scratch?" I have to ask, "What is the safest useful improvement I can make within the system that actually exists?"
That question is less exciting, but it is closer to professional engineering.
Reading unfamiliar code is part of the same lesson. Rewriting something can feel faster than understanding it, especially when the existing implementation does not match my preferences. But rewriting also discards decisions whose reasons may not be visible yet. Understanding first gives me a chance to separate accidental complexity from constraints that still matter.
Simple does not mean careless
I increasingly prefer solutions with fewer moving parts, but not because complexity is always bad.
Complexity can be necessary. Background jobs, caching, multiple services, connection pooling, asynchronous communication, or specialized infrastructure can all be justified. The mistake is introducing them before there is evidence that their cost is worth paying.
Every additional component creates another place to configure, observe, secure, deploy, and debug. A simple solution can therefore be more robust, not because it is less sophisticated, but because its behavior is easier to understand.
The best version of simplicity is deliberate. It understands the constraints and chooses the smallest system that still handles them correctly.
Communication is part of the technical work
Another change in my definition of engineering is that I no longer see communication as something surrounding the technical work. It affects the technical work directly.
If I cannot explain a limitation clearly, a user may make an incorrect assumption. If I do not ask the right question, I may encode that misunderstanding into the database. If I cannot describe a deployment risk to another developer, the team cannot make an informed decision about it.
Non-technical users do not need framework terminology. They need to understand what the software will do, what it will not do, and what changes in their workflow. Developers need a different level of detail, but the responsibility is similar: make important assumptions visible.
That is a technical skill because software is built from assumptions as much as it is built from code.
My current definition of a good developer
I still care about code quality. I want to understand frameworks deeply, design clean APIs, write maintainable modules, and become better at debugging. But I no longer think those things are sufficient on their own.
A good developer should be able to move between levels of the problem. They should understand what a user is trying to accomplish, trace that need through application logic and data, think about how the system will run in production, and anticipate what another developer will face when the code changes later.
They should also know when not to add something.
My internship did not give me a final definition of software engineering. If anything, it made the field feel larger. I became more aware of how much exists between an idea and a reliable system.
But that awareness changed the direction in which I want to improve. I do not want to become someone who can only build features quickly. I want to become an engineer who can understand why a system should exist, make careful choices while building it, and remain responsible for what happens after the code leaves my machine.