Using repository with an ORM
May 11, 2020
When you’re working on a legacy codebase, you might be in disagreement with the choices made long before you joined. One pattern that I encountered in every company I’ve worked at, is the spread of ORM domain models in the whole application. I’ve seen it in Java codebase with Hibernate (times two), I’ve seen it in Node.js codebase with Sequelize (times three).
I have nothing agains’t ORMs, they are great tools but unfortunately, they are most of the time used as the Domain Models in the application, and not as the implementation of the persistence layer of these Domain Models.
☝️ What I mean by that, is more easily explained with code:
import { Purchase } from "./models"; // Sequelize models
async function cancelPurchase(purchase_id) {
const purchase = await Purchase.findById(purchase_id);
purchase.update({ state: "cancelled" }); // purchase instance (of the ORM)
// other stuff...
await purchase.save();
}
In this case, the Purchase Sequelize instance is used directly by our business logic layer. So now, testing this business logic become more complicated because it’s tied to our persistence layer. So it either requires a complex setup to mock this dependency, or requires an even more complex setup to run the test with a test database. The first solution gives less confidence, the second is slower.
So how do you get the confidence you want from your test, but with an easier setup and with a faster feedback loop?
TLDR: Introduce a repository for your aggregate, and two implementations. Run the same test suite for both implementation of your aggregate repository, and then use the simpler, faster one for all other tests.
// Aggregate
class Purchase {
constructor(private id: EntityId, private state: State) {}
// some business method belonging to your aggregate
}
// Repository
interface PurchaseRepository {
save(purchase: Purchase): Promise<Purchase>;
findById(id: EntityId): Promise<Purchase>;
findAll(): Promise<Purchase[]>;
}
Let’s get back to our first example, but using the repository with the aggregsate, it becomes like this:
import { Purchase } from "./models"; // Real DDD domain model, without any persistence coupling
import { purchaseRepository } from "./configuration"; // responsible to instantiate the proper repository instance based on the environment for example
async function cancelPurchase(purchase_id) {
const purchase = await purchaseRepository.findById(purchase_id);
purchase.cancel(); // use your aggregate behavior
// other stuff...
await purchaseRepository.save(purchase);
}
Now I can write my test to use the fast implementation of the purchase repository: the InMemory one.
But how using another instance of the repository gives me confidence? Well, right now it does not. You need to have a test suite that is ran against both of your repository implementations, so you know that both follow the same contract, and can be interchangeable.
So let’s introduce the two implementation, and the common test suite:
class InMemoryPurchaseRepository implements PurchaseRepository {
constructor(private purchases: Purchase[] = []) {}
async save(purchase: Purchase) {
const foundIndex = this.purchases.findIndex(p => p.id === purchase.id);
if (!foundIndex) {
this.purchases.push(purchase); // insert
} else {
this.purchases.splice(1, foundIndex, purchase); // update
}
return purchase;
}
// omitting other methods
}
class SequelizePurchaseRepository implements PurchaseRepository {
constructor(private purchase_sequelize: SequelizeModel) {}
async save(purchase: Purchase) {
await this.purchase_sequelize.upsert(toDbModel(purchase)); // insert or update
return purchase;
}
// omitting other methods
}
Both implementation seems to have the same behaviour, but to be sure of that, we need to write a few tests for both of them.
describe("InMemoryPurchaseRepository", () => {
purchaseRepositoryTestSuite(
() => new InMemoryPurchaseRepository()
);
});
describe("SequelizePurchaseRepository", () => {
purchaseRepositoryTestSuite(
() => new SequelizePurchaseRepository(),
async () => Sequelize.destroy({})
);
});
function purchaseRepositoryTestSuite(
init: () => PurchaseRepository,
cleanup = async () => {}
) {
let repository: PurchaseRepository;
beforeEach(() => {
repository = init();
});
afterEach(async () => {
await cleanup();
});
it("saves a new purchase", async () => {
const purchase = await repository.save(new Purchase());
const result = await repository.findAll();
assert.toDeepEqual(result, [purchase]);
});
it("updates an existing purchase", async () => {
// omitted
});
it("find the purchase by id", async () => {
// omitted
});
}
With these tests, you know that both of your implementation behaves in the same way and have feature parity.
Therefore, you can use the InMemory implementation for all your tests that requires a purchase repository, and since the implementation is a simple map stored in memory, it is extremely fast, and does not require any specific setup. Testing your business logic is now a unit test, not an integration (as in integration with external dependencies like a DB) test.
As said before, using ORMs like Sequelize is not bad per say, they provide some great features for free, but only if you keep them outside of your domain models. Sequelize is simply an implementation details to persist your domain models. It is not supposed to be leaking into your whole domain layer.
Don’t let your persistence layer dictate how you model your domain, inverse the depedency 🙌