
Learn how to implement a clean Magento 2 repository pattern with model, resource, and collection classes, plus a CLI demo. Follow my step-by-step guide.
How I Turned a Chaos of DB Calls into Clean Code with a Magento 2 Repository Class
Published by Brav
Table of Contents
TL;DR
- I broke the monolithic model/resource code into a clean repository pattern.
- I created a Log module with Model, ResourceModel, Collection, RepositoryInterface, Repository, and CLI command.
- The repository decouples persistence logic, gives you a stable API, and make unit tests easier.
- You can add declarative schema, data patches, and CLI commands without touching the rest of your app.
- Follow my step-by-step guide and avoid common pitfalls.
Why this matters
As a Magento backend developer, I’ve seen the same frustrations over and over:
- A single class doing everything: loading, saving, deleting, and even rendering admin UI.
- Models that reach out to the database directly through \$this->resource->load(\$this, \$id), making unit tests brittle.
- Dependency injection of \$this->resource everywhere, leading to a tangled web of constructor parameters.
- Hard-coded table names that break when you deploy the module on another environment.
This chaos hurts performance, testability, and long-term maintenance. The repository pattern gives you a clean contract for persistence and keeps your business logic free of SQL concerns.
Core concepts
| Parameter | Use case | Limitation |
|---|---|---|
| Model | Holds data for a single entity, e.g. a Log row. | Must always be paired with a resource model; cannot persist by itself. |
| ResourceModel | Maps a model to the database table magemastery_log and knows the primary key log_id. | Directly accessing the database; if you change the schema you must update this class. |
| Collection | Loads a list of models; pre-populates data for bulk operations. | Not ideal for complex queries that need joins or aggregations; those go into a repository. |
The Repository sits above all three. It implements an interface (LogRepositoryInterface) that declares save and getById. Its implementation (LogRepository) uses the LogResource to persist data and the LogFactory to create new model instances. In di.xml we declare a preference that tells Magento’s object manager to resolve LogRepositoryInterface to LogRepository.
This separation gives you:
- A stable API for your module consumers (even if you rename tables later).
- Easy unit testing: mock the repository interface.
- Centralized error handling: throw NoSuchEntityException once in the repository instead of scattering it everywhere.
How to apply it
Below is a hands-on walk-through of the exact files I created for a tiny Mage Mastery Log module. Feel free to copy-paste, rename, and tweak.
1. Create the module skeleton
app/code/MageMastery/Log/
├─ etc
│ ├─ module.xml
│ └─ di.xml
├─ Api
│ └─ LogRepositoryInterface.php
├─ Model
│ ├─ Log.php
│ ├─ LogFactory.php
│ └─ LogRepository.php
├─ Model/ResourceModel
│ ├─ Log.php
│ └─ LogCollection.php
├─ Setup
│ ├─ InstallSchema.php
│ └─ InstallData.php
└─ Console
└─ DemoCommand.php
2. Define the repository interface
<?php
namespace MageMastery\\Log\\Api;
use MageMastery\\Log\\Api\\Data\\LogInterface;
interface LogRepositoryInterface
{
/**
* Save a log record.
*
* @param LogInterface $log
* @return LogInterface
* @throws \\Magento\\Framework\\Exception\\LocalizedException
*/
public function save(LogInterface $log);
/**
* Load a log record by ID.
*
* @param int $id
* @return LogInterface
* @throws \\Magento\\Framework\\Exception\\NoSuchEntityException
*/
public function getById($id);
}
3. Implement the repository
<?php
namespace MageMastery\\Log\\Model;
use MageMastery\\Log\\Api\\LogRepositoryInterface;
use MageMastery\\Log\\Api\\Data\\LogInterface;
use MageMastery\\Log\\Model\\ResourceModel\\Log as LogResource;
use MageMastery\\Log\\Model\\LogFactory;
use Magento\\Framework\\Exception\\NoSuchEntityException;
class LogRepository implements LogRepositoryInterface
{
private $factory;
private $resource;
public function __construct(LogFactory $factory, LogResource $resource)
{
$this->factory = $factory;
$this->resource = $resource;
}
public function save($log)
{
$this->resource->save($log);
return $log;
}
public function getById($id)
{
$log = $this->factory->create();
$this->resource->load($log, $id);
if (!$log->getId()) {
throw new NoSuchEntityException(__('Log with ID %1 not found', $id));
}
return $log;
}
}
4. Hook the interface to the implementation
In etc/di.xml:
<preference for="MageMastery\\Log\\Api\\LogRepositoryInterface" type="MageMastery\\Log\\Model\\LogRepository"/>
5. Create the model, factory, and resource
Model/Log.php
<?php
namespace MageMastery\\Log\\Model;
use Magento\\Framework\\Model\\AbstractModel;
use MageMastery\\Log\\Api\\Data\\LogInterface;
class Log extends AbstractModel implements LogInterface
{
protected function _construct()
{
$this->_init(MageMastery\\Log\\Model\\ResourceModel\\Log::class);
}
}
Model/ResourceModel/Log.php
<?php
namespace MageMastery\\Log\\Model\\ResourceModel;
use Magento\\Framework\\Model\\ResourceModel\\Db\\AbstractDb;
class Log extends AbstractDb
{
protected function _construct()
{
$this->_init('magemastery_log', 'log_id');
}
}
Model/ResourceModel/LogCollection.php
<?php
namespace MageMastery\\Log\\Model\\ResourceModel;
use Magento\\Framework\\Model\\ResourceModel\\Db\\Collection\\AbstractCollection;
class LogCollection extends AbstractCollection
{
protected function _construct()
{
$this->_init(
MageMastery\\Log\\Model\\Log::class,
MageMastery\\Log\\Model\\ResourceModel\\Log::class
);
}
}
6. Declarative schema & data patch
Create Setup/InstallSchema.php to create magemastery_log:
<?php
namespace MageMastery\\Log\\Setup;
use Magento\\Framework\\DB\\Ddl\\Table;
use Magento\\Framework\\Setup\\InstallSchemaInterface;
use Magento\\Framework\\Setup\\ModuleContextInterface;
use Magento\\Framework\\Setup\\SchemaSetupInterface;
class InstallSchema implements InstallSchemaInterface
{
public function install(SchemaSetupInterface $setup, ModuleContextInterface $context)
{
$table = $setup->getConnection()
->newTable($setup->getTable('magemastery_log'))
->addColumn(
'log_id',
Table::TYPE_INTEGER,
null,
['identity' => true, 'nullable' => false, 'primary' => true],
'Log ID'
)
->addColumn(
'message',
Table::TYPE_TEXT,
'64k',
['nullable' => false],
'Log message'
)
->setComment('Mage Mastery Log Table');
$setup->getConnection()->createTable($table);
}
}
Setup/InstallData.php inserts two demo records:
<?php
namespace MageMastery\\Log\\Setup;
use Magento\\Framework\\Setup\\InstallDataInterface;
use Magento\\Framework\\Setup\\ModuleContextInterface;
use Magento\\Framework\\Setup\\ModuleDataSetupInterface;
use Magento\\Framework\\DB\\Ddl\\Table;
class InstallData implements InstallDataInterface
{
public function install(ModuleDataSetupInterface $setup, ModuleContextInterface $context)
{
$logTable = $setup->getTable('magemastery_log');
$data = [
['message' => 'Log record #1'],
['message' => 'Log record #2'],
];
$setup->getConnection()->insertMultiple($logTable, $data);
}
}
7. Create a CLI command that uses the repository
Console/DemoCommand.php
<?php
namespace MageMastery\\Log\\Console;
use Symfony\\Component\\Console\\Command\\Command;
use Symfony\\Component\\Console\\Input\\InputInterface;
use Symfony\\Component\\Console\\Output\\OutputInterface;
use MageMastery\\Log\\Api\\LogRepositoryInterface;
use MageMastery\\Log\\Model\\LogFactory;
class DemoCommand extends Command
{
const MESSAGE = 'Demo log message';
private $repo;
private $factory;
public function __construct(LogRepositoryInterface $repo, LogFactory $factory)
{
$this->repo = $repo;
$this->factory = $factory;
parent::__construct();
}
protected function configure()
{
$this->setName('mage:log:demo')
->setDescription('Demo log command');
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$log = $this->factory->create();
$log->setMessage(self::MESSAGE);
$this->repo->save($log);
$output->writeln(sprintf('Saved log id %d', $log->getId()));
return 0;
}
}
Run bin/magento mage:log:demo. You’ll see:
Saved log id 5
The record ID 5 and message come straight from the repository, proving the pattern works.
8. Register the command
Add the command to etc/di.xml:
<type name="MageMastery\\Log\\Console\\DemoCommand">
<arguments>
<argument name="logRepository" xsi:type="object">MageMastery\\Log\\Api\\LogRepositoryInterface</argument>
<argument name="logFactory" xsi:type="object">MageMastery\\Log\\Model\\LogFactory</argument>
</arguments>
</type>
Then run bin/magento setup:upgrade to register the command. The CLI now lives in bin/magento without any manual registration.
Pitfalls & edge cases
| Issue | What went wrong | Fix |
|---|---|---|
| NoSuchEntityException | The repository throws when an ID is missing. | Wrap calls in try { } catch (NoSuchEntityException $e) {} |
| Over-reliance on ResourceConnection | Skipping the repository and calling $this->resource->load() in business logic breaks testability. | Always go through the repository; keep models as simple data holders. |
| Performance hit on bulk ops | Using the repository to load each record one by one can be slow. | Use the collection for bulk reads ($this->logCollectionFactory->create()->load()), or add a custom repository method that returns a collection. |
| Transactions | Multiple repository calls are not wrapped in a DB transaction, so partial saves can leave data in an inconsistent state. | Use Magento’s \Magento\Framework\DB\Transaction in the repository if you need atomicity. |
| Circular dependencies | If the repository injects the model factory, and the model injects the repository (rare), you get a circular DI. | Keep the model stateless; inject only the repository into the services that need it. |
Quick FAQ
Why use a repository instead of the resource directly? Repositories decouple persistence logic, give you a stable API, and make unit tests easier.
Can I add custom query methods to a repository? Yes, add public methods that use $this->resource->getConnection() or $this->collectionFactory->create() as needed.
How do I handle pagination? Use Magento’s SearchCriteria and SearchResult classes in the repository to return paged results.
Do I need to update di.xml each time I add a method? No, only the repository implementation changes; the interface remains the same.
What if I need to support multiple tables? Create a new repository and resource model for each entity; the pattern scales cleanly.
Can I unit test the repository? Absolutely. Mock the LogFactory and LogResource and assert that save() and getById() behave correctly.
Is there a performance penalty? The overhead is negligible compared to direct database calls, but keep bulk operations efficient by using collections.
Conclusion
Adopting the repository pattern in Magento 2 gives you:
- Cleaner code that separates business logic from persistence.
- Easier testing because you can mock a simple interface.
- Scalable architecture: adding new entities is just a few new classes.
- Better maintainability: schema changes stay in one place (resource or schema file).
If you’re working on a module that will grow, start with a repository today. If you’re still doing one-off scripts, consider refactoring a few key classes to a repository for future-proofing.
Next steps for you:
- Identify an existing model that’s tightly coupled to its database table.
- Create the corresponding repository interface and implementation.
- Update your business services to depend on the interface, not the model.
- Run bin/magento setup:upgrade and test with a CLI or API call.
Happy coding!