Fetching the product a customer is currently viewing sounds like it should be a one-liner. On most platforms it would be. In Magento it's one of those deceptively deep corners; the task isn't difficult, it's just that the "right" way isn't settled. There are three legitimate approaches, each with real tradeoffs, and understanding why there are three is more useful than memorizing any one of them.

Why there's no single answer

Loading a product is clean: if you have an id or SKU, ProductRepositoryInterface::getById() is the blessed way. The ambiguity is specific to the current product; the one implicitly bound to the request you're handling. That's a different kind of problem: ambient page context, not loading a named entity.

This mess has history. Magento orignally exposed the current product through a global registry - effectively a global variable bag. That's global mutable state, and Magento eventually deprecated it: hidden coupling, hard-to-trace mutations, and poor testability are all valid reasons why. The catch is that it was deprecated without a designated replacement. The deprecation note points vaguely at "service classes or data providers" and names no successor. A deprecation with no heir breeds a field of competing community conventions, none of which are official.

Here sits a real architectural tension: clean dependency injection is hostile to the idea of an ambient "current" anything floating in global scope. The DI-pure stance is that there shouldn't be a magic product at all; you should read the id from the request and load it explicitily. That's more verbose, so the old registry never quite died.

The three approaches

1. The registry: Magento\Framework\Registry::registry('current_product') In-memory, used throughout core and community code. Deprecated, and couples to global state.

2. Request + repository: Read the product id from the request, load it via ProductRepositoryInterface. A DI-pure and registry-free approach. This however requires more wiring, and it re-loads a product that's already resident in memory.

3. The Catalog Helper: Magento\Catalog\Helper\Data::getProduct(). Uses the registry internally, but you never touch the registry yourself.

Making a decision

For most cases, and especially a frontend widget, the Catalog Helper is the pragmatic pick:

  • It's registry-free in your code: no deprecation warning, no direct dependence on deprecated global state.
  • One injected dependency, one call. It returns the already-loaded product, without the redundant lookup added by a request + repository route.
  • It delegates the deprecated internals to the framework. The helper still reads from the registry under the hood, but that's Magento's concern - not yours. If the underlying mechanism is ever refactored, the helper moves with it, and your code remains untouched.

The tradeoff here is that helpers are a mildly dated pattern and this one leans on the registry interally. For getting the current product to render a widget, that's a price worth paying. If you need strict DI purity, take the request + repository route instead.

The same pattern elsewhere: undocumented convention strings

A related corner worth a mention, because it's the same phenomenon. Magento's image helper takes a role string:

$imageHelper->init($product, 'product_base_image')->getUrl()

product_base_image isn't validated or enumerated anywhere central - that helper accepts whatever you pass and looks it up. These "image roles" are declared in view.xml, spread across core modules and the parent theme; your own theme's view.xml shows only your overrides, so there's no single master list to consult. You learn the available roles by reading core and parent-theme view.xml, or from prior exposure. This lack of a "documented contract" is structurally the same problem as the current-product one above.

The broader principle

Both cases share a lesson worth thinking about for any mature framework: some corners have no canonical answer, thus you need to make a decision based on the defensible options. Making this decision is about choosing a reasonable approach against tradeoffs, then shipping. You cannot hunt for a "correct" answer that doesn't exist. Knowing where to look - this is the real engineering skill. In a codebase of this size, being fluent is about knowing where the answers live. No one developer can hold everything in their head.