← All posts 11 min read

Programmer, Software Engineer, Product Engineer

One framework, three depths of understanding. Three engineers can all put Spring Boot on their resumes — what separates them is not years of experience, but the question each one asks.

Programmer, Software Engineer, Product Engineer

Programmer. Software Engineer. Product Engineer.

Three engineers can all write “Spring Boot” on their resumes. They can all build a REST API, connect a database, and use the usual annotations. The difference between them is not years of experience. It is the question each one asks.

  • “What code should I write?”
  • “Why does it work this way?”
  • “How will it behave in the real world — and is it the right thing to build?”

@Programmer(focus = "syntax", goal = "make it work

Learns the how

asks: "what do I write here?

A programmer often learns the visible parts of Spring Boot first: how to create a project, write a controller, add a service, connect a repository, and return JSON. They memorize annotations such as @Autowired, @RestController, @Service, and @Transactional, then follow the familiar structure:

Controller → Service → Repository → Database

They may also learn that a method such as findByEmailAndStatus can automatically generate a database query. This structure is useful. The problem is not following a standard pattern; the problem is using it without understanding why it exists.

When the application behaves differently from the tutorial—when a transaction does not roll back or a bean fails to inject—the programmer does not know where to begin. They search for the error, copy a solution, and move on. This is a real and necessary stage of learning. Everyone starts there. The danger is remaining there. The way forward is not to memorize more annotations. It is to ask one deeper question:

What problem is this feature trying to solve?

@SoftwareEngineer(focus = "purpose", goal = "design deliberately")

Learns the why

asks: "what problem was this built to solve?"

The software engineer begins one level deeper. Instead of asking, “How do I use dependency injection?” they ask, “What problem is dependency injection meant to solve?”

The answer is coupling. When a class creates all of its own dependencies, it becomes tightly connected to specific implementations, making it harder to test, modify, or reuse. With dependency injection, the class receives what it needs from the outside, allowing those dependencies to be replaced or changed without altering the class itself. There is no magic involved—it is a deliberate design choice that reduces coupling and makes the system easier to evolve.

The engineer also understands the broader purpose of the framework. Spring is an abstraction designed to simplify the development of enterprise systems—systems that must support scalability, extensibility, maintainability, and reliable performance. As these systems grow, they repeatedly encounter the same fundamental challenges:

  • Creating and connecting objects.
  • Managing transactions.
  • Applying security consistently.
  • Organizing database access.
  • Adding logging without duplicating code everywhere.

EJB was Java’s early attempt to address these problems. Spring did not simply “fix EJB.” It addressed many of the same problems through a simpler programming model. Over time, Spring became complex in its own way, and Spring Boot reduced that complexity through starters, auto-configuration, and embedded servers.

At this level, the engineer also understands how Spring works. There is no magic. When the application starts, Spring scans the classpath, uses Java reflection to inspect class annotations, creates the required objects, and connects their dependencies—even when those dependencies are declared in private fields.

Features such as @Transactional and AOP typically work through dynamic proxies. Spring wraps a bean in a generated object that performs additional behavior before or after the target method runs. Understanding this turns confusing behavior—such as an annotation appearing to do nothing or a bean not being intercepted—into something the engineer can investigate logically.

Patterns also become tools rather than rituals. The engineer understands Inversion of Control, Repository, Proxy, and AOP. But they also understand that a pattern is useful only when it helps the system under its specific conditions. Adding five layers around a simple operation may create more complexity than value.

The same reasoning applies to architecture. Microservices are not automatically better than a modular monolith. The important questions are:

  • Do the services need to be deployed independently?
  • Can the team operate the additional infrastructure?
  • What network failures and operational costs are being introduced?

Every architectural decision now has a reason behind it. There is also a direct, practical benefit: more testable code. The purpose of loose coupling is not theoretical elegance. It allows a real dependency to be replaced with a fake one during testing. The software engineer uses Spring Boot’s testing capabilities deliberately:

  • Plain unit tests for business logic.
  • Focused tests such as @WebMvcTest and @DataJpaTest for individual layers.
  • @SpringBootTest with Testcontainers when the real database or infrastructure matters.

Depth is not just about understanding framework internals. It appears every day in code that is easier to reason about, easier to change, and easier to test.

@ProductEngineer(focus = "live system + customer", goal = "the right thing, running well")

Learns the machine and its purpose

asks: "how will this behave after twelve months live — and is it the right thing to build?"

The product engineer owns the product end to end: both the running system and the business outcome it is meant to deliver. A software engineer understands how the mechanism works; a product engineer also understands what that mechanism costs when the system is running in production.

They know which server handles incoming requests—Tomcat in a typical Spring MVC application and Netty in a reactive WebFlux application—and how filters and interceptors process those requests. They understand that classpath scanning contributes to startup time and that a proxy-managed transaction can hold a database connection for the entire duration of a transaction. They do not need to memorize the framework’s source code. They know enough about its internals to form a useful explanation when the live system behaves unexpectedly.

They also understand that most systems work well during their early weeks. The difficult problems often appear later, as usage and data grow. Tables become large enough for queries to slow down. A JPA relationship that lazily loads a collection can turn one query into hundreds—the classic N+1 problem. Connection pools become exhausted. Caches return stale data. Retries create duplicate operations. A small failure in one service propagates to others.

That is why the product engineer prepares for these problems early. They use Spring Boot Actuator for health checks, Micrometer for metrics, and OpenTelemetry for distributed tracing. They define timeouts, limit retries, and create alerts based on symptoms users actually experience. They keep configuration outside the application code—using application.yml, environment-specific profiles, and properly managed secrets—so the same application artifact can run across development, staging, and production.

They treat security as real engineering work: understanding how tokens are handled, how services authenticate one another, and where trust boundaries exist. They plan capacity, deployment, and operational needs deliberately. Once data is distributed across multiple services, they also know that @Transactional alone cannot guarantee consistency. Patterns such as sagas, transactional outboxes, and idempotency are needed to keep business operations reliable across service boundaries.

But the product engineer brings one additional lens that the first two do not: the customer.

Who is the product for? What is the smallest useful version we can release? What does each request cost to serve, and is the business willing to pay for that cost? How will we know the feature succeeded—through adoption, conversion, retention, or repeat usage?

They understand that technical quality and product value are not the same thing. A beautifully scalable service that solves an unimportant problem is not a success. A simple modular monolith that solves a genuine customer problem may be the better engineering decision.

Making that trade-off well is possible only when the engineer understands both sides: the customer need and the machine underneath.


One bug, three readings

A method marked @Transactional fails, but nothing rolls back.

Googles it, finds the fix (“make the method public and call it through another bean”), applies it, moves on. The fix works. The mystery stays.

@SoftwareEngineer

Knows the annotation works through a proxy that wraps the class. A private method, or a method called from inside the same class, never passes through the proxy — so no transaction starts. The fix is now derived from a mental model, not copied from a search result.

@ProductEngineer

Also asks how this transaction behaves under real load: how long it holds a database connection, whether it makes a slow network call while open, whether a timeout could trigger a retry that repeats the operation. Then goes one step further: does this transaction boundary even match the real business operation? What genuinely must be atomic, what can safely happen later, and what should the customer see if one step fails? The bug has become a design review — and a product decision.


Dimensions, not a ladder

It is tempting to read these as a straight line every engineer climbs in order. Real growth is messier. One person can be excellent at implementation but new to production, or strong in system design but still learning the business. These are dimensions of maturity, not ranks — and the best teams make room for all three questions in every design discussion.


How to grow deeper

  1. Learn the reason before the recipe. When reading docs, don't stop at the code example — ask what problem the feature solves and what trade-off it brings.
  2. Run small experiments. Build a tiny app and test one question at a time: does self-invocation start a transaction? What happens with a pool of only two connections? One experiment teaches more than ten tutorials.
  3. Read the source when the magic surprises you. Not the whole framework — just the part behind the surprise: proxy creation, transaction interceptors, auto-configuration conditions.
  4. Study production evidence. Logs, traces, slow queries, incident reports. A postmortem teaches lessons no tutorial can, because it shows which of your assumptions were wrong.
  5. Connect decisions to outcomes. For every big design choice, ask which customer problem it serves, what risk it reduces, what complexity it adds — and how you will know if it helped.

The takeaway

Knowing Spring Boot is not one thing. You can know how to use it, why it exists, and how it lives, fails, and earns its keep in the real world — and each depth changes what you build.

Frameworks come and go: EJB was a bridge, Spring was a better one, and Spring Boot will not be the last. The problems they solve — and the customers they serve — stay. The durable skill is not memorizing today's annotations. It is asking better questions.Whatever depth you are at today, ask the next deeper one.

August 18, 2026
← Back to all posts