Yuow
is a generic implementation of Unit of Work, Repository and IdentityMap patterns built on top of Knex library.
With Yuow
you can build a truly isolated domain model.
See examples folder
npm install yuow
Yuow
requires you to implement Data Mapper and Repository for each your model.
import { uowFactory } from 'yuow';
const uow = uowFactory(/* pass knex instance here */);
await uow(async (ctx) => {
const userRepository = ctx.getRepository(UserRepository);
const user = await userRepository.findById(userId);
if (!user) {
throw new Error('User not found');
}
user.changeName(name);
return {
id: user.id,
name: user.name
};
});
uow(unit, {
globalTransaction: false,
isolationLevel: 'read commited',
retries: 3
});
Yuow
offers two ways to handle transactions. By default globalTransaction
is false
, which means that only computed changes will be queried inside a database transaction. This approach works well if you want to implement an optimistic concurency control.
If you pass true
, all database interactions inside unit of work will be wrapped with one global transaction. This is helpful in case you need a pessimistic concurency control.
retries
specifies how many times unit of work must be retried before it throws an error. Retries are perfromed only if PersistenceError
is thrown. Check out Data Mapper section to see when it's thrown.
When globalTransaction
is set to false
, retries is equal 3
by default, otherwise it is 0
.
You can set the same isolation level as provided by Knex library.
The Data Mapper is a layer of software that separates the in-memory objects from the database. Its responsibility is to transfer data between the two and also to isolate them from each other
Your data mapper must extend an abstract DataMapper
class exported from Yuow
package.
There are only three required abstract methods: insert
, update
and delete
. Selection is also a necessary operation, but it is not as trivial as others, so you will need to implement it on your own.
import { DataMapper } from 'yuow';
import { Customer } from './model/customer';
export class CustomerDataMapper extends DataMapper<Customer> {
async findById(id: string): Promise<Customer | undefined> {
// There can be different variations of selection: findOne, findMany, findByName and e.t.c. You can implement any of them.
}
async insert(customer: Customer): Promise<boolean> {
// Implement
}
async update(customer: Customer): Promise<boolean> {
// Implement
}
async delete(customer: Customer) : Promise<boolean> {
// Implement
}
}
In order to load an entity from database, create any method that hydarate your entities and return them in any form: it can be a single entity, an array of entities, a map of entites and e.t.c.
In this example, we create a findById
method that returns Customer
entity or undefined
.
async findById(id: string): Promise<Cutomer | undefined> {
// 1. Request a record from database
const record = await this.knex
.select('*')
.from('customers')
.where('id', id)
.first();
// 2. Return undefined if a record was not found
if (!record) {
return;
}
// 3. Hydrate Customer entity
const customer = new CustomerHydrator({
id: record.id,
name: record.name,
});;
// 4. Remember its version
this.setVersion(customer, record.version);
// 5. Return
return customer;
}
As you can see, this is mostly a trivial operation. But some of the step can raise questions. Let's dive into them.
You can decide to make constructors protected in order to protect your domain model invariants. This is neccessary if you want the domain model classes to describe exisiting analytic domain model as close as possible.
In such cases hydrators can be used to "recover" your domain model state from database.
Hydradors are just classes that extend your domain model and make constructors public.
import { Customer, CustomerState } from './model/customer';
export class CustomerHydrator extends Customer {
constructor(state: CustomerState) {
super(state);
}
}
Versioning is a common approach to implement optimistic concurency control.
Abstract DataMapper provides 3 methods that makes versioning easy: setVersion
, increaseVersion
and getVersion
.
This is an optional step and can be avoided of you are going to use pessimistic concurency control.
Insert, delete and update methods are necessary to be able to persist your domain model state.
Those methods are pretty trivial and structurually the same.
async insert(customer: Customer) {
// 1. Get version
const version = this.getVersion(customer);
// 2. Insert
const result = await this.knex
.insert({
id: customer.id,
name: customer.name,
version,
})
.into('customers');
// 3. Return result
return (result[0] || 0) > 0;
}
async update(customer: Customer) {
// 1. Increase version
const version = this.increaseVersion(customer);
// 2. Update
const result = await this.knex('customers')
.update({
id: customer.id,
name: customer.name,
version,
})
.where('customers.id', customer.id)
.andWhere('customers.version', version - 1);
// 3. Return result
return result > 0;
}
async delete(customer: Customer) {
// 1. Get version
const version = this.getVersion(customer);
// 2. Delete
const result = await this.knex
.delete()
.from('customers')
.where('customers.id', customer.id)
.andWhere('customers.version', version);
// 3. Return result
return result > 0;
}
In the example above method getVersion
is used to get a current version of an entity, if no version has been previusoly set using setVersion
method it will return 1
. Method increaseVersion
increaes version by one and returns it.
It's necessary to always return a boolean result of an operation. Depending on the result, Youw
decides whether to throw PersistenceError
and retry an operation.
A Repository mediates between the domain and data mapping layers, acting like an in-memory domain object collection
Yuow
requires you to create a simple repository in order to perform entities manipulation.
Only two methods and properties are required: extractIdentity
and [Repository.DataMapper]
. Also, you should mirror your selection methods from data mapper.
import { Repository } from 'yuow';
import { CustomerDataMapper } from './data-mappers/customer.data-mapper';
import { Customer } from './model/customer';
export class CustomerRepository extends Repository<
Customer,
CustomerDataMapper
> {
protected [Repository.DataMapper] = /* Implement */;
protected extractIdentity(customer: Customer) {
// Implement
}
async findById(...args: Parameters<CustomerDataMapper['findById']>) {
// Implement
}
}
Set it equal to your Data Mapper constructor as shown below:
protected [Repository.DataMapper] = CustomerDataMapper;
Once it's done, you can directly access the data mappers' instance by referencing this.mapper
property.
To emulate a collection-like behaviour, a repository uses an Identity Map pattern to keep identity <–> entity references. Since, with Yuow
your domain model can live truly isolated, it's necessary to give the repository information on how to extract identity from your entity.
protected extractIdentity(customer: Customer) {
return customer.id;
}
To use selection methods from your data mapper, create a twin selection method and track result using this.trackAll
method.
async findById(...args: Parameters<CustomerDataMapper['findById']>) {
const result = await this.mapper.findById(...args);
return this.trackAll(result, 'loaded');
}
You can also use this.track
and this.untrack
to track/untrack each entity manually.
Yuow is MIT licensed.