To refactor a Magento 2 model to the repository pattern, move every load and save method off the model and into a ContactRepository that implements an Api\ContactRepositoryInterface. Then bind the interface to the class with a di.xml preference, and inject the interface everywhere. The model goes back to representing one row.
A loadByEmail() method on a model looks harmless. It's one line. It's also the reason a lot of custom modules can't be tested, swapped out, or exposed through the API.
The code with the problem
Here's the starting point: a Contact model with a loadByEmail() method bolted onto it.
<?php
declare(strict_types=1);
namespace Vendor\Module\Model;
use Magento\Framework\Model\AbstractModel;
class Contact extends AbstractModel
{
protected function _construct()
{
$this->_init(ResourceModel\Contact::class);
}
public function loadByEmail($email)
{
$this->_getResource()->load($this, $email, 'email');
return $this;
}
}It returns a contact record by email address, and it works. The problem is where it lives. A model that loads itself is doing two jobs, and that breaks a handful of design principles at once:
- Single Responsibility. The Single Responsibility Principle (the S in SOLID) says a class should have one job. This model both represents a contact and knows how to look contacts up.
- Service contracts. Magento's service contracts are the public PHP interfaces for a module's functionality. Other code is supposed to depend on those interfaces, so you can swap the implementation without touching the callers. A lookup method on a concrete model has no interface at all.
- Separation of concerns. Separation of concerns keeps data access apart from the thing being accessed.
loadByEmail()mixes the two. - DRY. One lookup method on the model sets the precedent. Next comes
loadByPhone(), then a second copy of the email lookup somewhere else, and Don't Repeat Yourself goes out the window.
Why a repository fixes it
The fix is to move data access into a repository class behind an interface, which is the same pattern Magento core uses for products, customers, CMS pages and pretty much everything else.
Each violation above goes away:
- The model only represents the contact entity.
ContactRepositoryInterfaceis the service contract, so callers depend on an interface and you can swap the implementation with a preference, plugin or proxy.- Data access lives in one place, so a change to how contacts load happens once.
Why not just use a collection?
A collection would technically work too, but a repository is the better choice here:
- Abstraction. Callers don't know or care how the data is stored. You can change the storage logic without touching them.
- Testability. You inject
ContactRepositoryInterfacethrough dependency injection, so a unit test can hand in a mock instead of hitting the database. - Consistency. Every module that follows the pattern reads the same way:
getById(),save(),delete(). Other developers on the team know where to look. - APIs. If you ever expose contacts through the web API, the repository interface is already the entry point, so the API and your internal code share the same business logic.
Repositories also give you one obvious place to add caching later.
Refactoring the code to the repository pattern
This assumes you already have a vendor_module_contact table (from db_schema.xml) with contact_id and email columns. There are six pieces: a data interface, the model, the resource model, the repository interface, its implementation, and the di.xml that ties them together.
Step 1: Define the data interface
The repository methods accept and return a ContactInterface, so it has to exist, and the model has to implement it:
<?php
declare(strict_types=1);
namespace Vendor\Module\Api\Data;
interface ContactInterface
{
public const CONTACT_ID = 'contact_id';
public const EMAIL = 'email';
/**
* @return int|null
*/
public function getId();
public function getEmail(): ?string;
public function setEmail(string $email): self;
}getId() has no native return type on purpose, because it has to stay compatible with AbstractModel::getId().
Step 2: Slim down the model and implement the interface
The loadByEmail() method goes away, and the model implements ContactInterface:
<?php
declare(strict_types=1);
namespace Vendor\Module\Model;
use Magento\Framework\Model\AbstractModel;
use Vendor\Module\Api\Data\ContactInterface;
class Contact extends AbstractModel implements ContactInterface
{
protected function _construct()
{
$this->_init(ResourceModel\Contact::class);
}
public function getEmail(): ?string
{
return $this->getData(self::EMAIL);
}
public function setEmail(string $email): ContactInterface
{
return $this->setData(self::EMAIL, $email);
}
}The resource model is the usual one-liner that points at the table and its primary key:
<?php
declare(strict_types=1);
namespace Vendor\Module\Model\ResourceModel;
use Magento\Framework\Model\ResourceModel\Db\AbstractDb;
class Contact extends AbstractDb
{
protected function _construct()
{
$this->_init('vendor_module_contact', 'contact_id');
}
}Step 3: Create the repository interface
The interface defines what the module can do with contacts. The email lookup moves here as getByEmail(), following the core naming convention (getById(), get($sku), getList()):
<?php
declare(strict_types=1);
namespace Vendor\Module\Api;
use Magento\Framework\Exception\CouldNotDeleteException;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\NoSuchEntityException;
use Vendor\Module\Api\Data\ContactInterface;
interface ContactRepositoryInterface
{
/**
* @throws NoSuchEntityException
*/
public function getById(int $contactId): ContactInterface;
/**
* @throws NoSuchEntityException
*/
public function getByEmail(string $email): ContactInterface;
/**
* @throws CouldNotSaveException
*/
public function save(ContactInterface $contact): ContactInterface;
/**
* @throws CouldNotDeleteException
*/
public function delete(ContactInterface $contact): bool;
}Step 4: Implement the repository
The concrete class does the actual loading and saving. Note the NoSuchEntityException checks. A resource model load() that finds nothing doesn't fail, it just leaves you with an empty model. Core repositories throw instead, so callers can't accidentally work with a contact that doesn't exist:
<?php
declare(strict_types=1);
namespace Vendor\Module\Model;
use Magento\Framework\Exception\CouldNotDeleteException;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\NoSuchEntityException;
use Vendor\Module\Api\ContactRepositoryInterface;
use Vendor\Module\Api\Data\ContactInterface;
use Vendor\Module\Model\ResourceModel\Contact as ContactResource;
class ContactRepository implements ContactRepositoryInterface
{
public function __construct(
private readonly ContactResource $contactResource,
private readonly ContactFactory $contactFactory,
) {
}
public function getById(int $contactId): ContactInterface
{
$contact = $this->contactFactory->create();
$this->contactResource->load($contact, $contactId);
if (!$contact->getId()) {
throw new NoSuchEntityException(
__('Contact with id "%1" does not exist.', $contactId)
);
}
return $contact;
}
public function getByEmail(string $email): ContactInterface
{
$contact = $this->contactFactory->create();
$this->contactResource->load($contact, $email, ContactInterface::EMAIL);
if (!$contact->getId()) {
throw new NoSuchEntityException(
__('Contact with email "%1" does not exist.', $email)
);
}
return $contact;
}
public function save(ContactInterface $contact): ContactInterface
{
try {
$this->contactResource->save($contact);
} catch (\Exception $e) {
throw new CouldNotSaveException(
__('Could not save the contact: %1', $e->getMessage()),
$e
);
}
return $contact;
}
public function delete(ContactInterface $contact): bool
{
try {
$this->contactResource->delete($contact);
} catch (\Exception $e) {
throw new CouldNotDeleteException(
__('Could not delete the contact: %1', $e->getMessage()),
$e
);
}
return true;
}
}ContactFactory doesn't need to exist as a file. Magento generates it the first time it's requested (see factories).
Step 5: Bind the interfaces in di.xml
This is the step that's easy to forget. Without it, injecting ContactRepositoryInterface fails with "Cannot instantiate interface", because Magento has no idea which class to build:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<preference for="Vendor\Module\Api\ContactRepositoryInterface" type="Vendor\Module\Model\ContactRepository"/>
<preference for="Vendor\Module\Api\Data\ContactInterface" type="Vendor\Module\Model\Contact"/>
</config>If preferences are new to you, the mapping interfaces to concrete classes lesson walks through it, and implementing concrete classes shows the same fix on a real piece of bad code.
Clean the config cache with bin/magento cache:clean config (and run bin/magento setup:di:compile if you're in production mode), and you can now inject Vendor\Module\Api\ContactRepositoryInterface anywhere, for example into the controllers you create in Magento:
try {
$contact = $this->contactRepository->getByEmail($email);
} catch (NoSuchEntityException $e) {
// No contact with that email -- handle it here.
}Do I need getList()?
Not for this refactor, so I left it out. Core repositories also have a getList(SearchCriteriaInterface $searchCriteria) method that returns a search results object, and that's the method that makes a repository usable for filtered lists from the web API. Add it when you need to query more than one contact at a time, or when you expose the entity through the API.
The trade-offs
The old method had real upsides. It was less code, it was quicker to write, and it was easier to follow for someone new to Magento.
The refactor costs you more code up front, plus a few extra classes and the time it takes to get comfortable with the pattern. In return you get:
- Code that follows the same conventions as Magento core, so it's easier for other developers to read and maintain.
- One place for data access, so there's no duplicated lookup logic to keep in sync.
- An interface you can mock in tests, swap with a preference, or expose through the API.
- Smoother upgrades, because your callers depend on your own interface instead of on model internals.
For a throwaway prototype, the model method is fine. For anything that ships, I'd take the repository every time.
Spotting this problem in your own code
A few signs that a class needs the same treatment:
- It breaks a design principle. SOLID, DRY, KISS and YAGNI violations are usually the first sign something is in the wrong place.
- It ignores Magento's own patterns. If you need a refresher on service contracts, repositories and factories, the Magento 2 Coding Kickstart course covers them in detail.
- It's hard to understand. Code that's hard to follow usually wants a different pattern or a refactor.
- Nobody has reviewed it. Regular code reviews with another developer catch this stuff early.
When you're choosing a pattern, start with the problem you're actually trying to solve, weigh the trade-offs (complexity, maintainability, testability) instead of applying a pattern blindly, and be willing to change your mind later. It also helps to know the common creational, structural and behavioral patterns, plus Magento's own repositories, factories and proxies (the Proxy Classes course goes deep on that last one).
Once your module has a repository, the next step is checking it automatically. Static analysis with PHPStan catches the kind of type mistakes this refactor is meant to prevent. And if you've refactored a model like this and hit a snag I didn't cover here, I'd like to hear about it.



