I’ve recently started working on a fairly complex application regarding the structure and the amount of data I get from our API. A lot of the data is meant to be reused and some of them reference the same relationships. This meant that I would, most likely, get tangled up with having some of the data not properly updated.
A while back, a colleague suggested to me to try out Vuex ORM. He reasoned that it should help a lot with nested data and act as a single source of truth. Its job is to map the data you receive from the back-end, after all.
I was convinced, so I’ve read the documentation, tried it out and all I can say is that it made my life a whole lot simpler! In addition to storing the data, I was amazed by how easy it is to get the specific data (and its relationships), format it and filter it by using the query builder. I also realized that a lot of these features would not be properly utilized if you had a simple application in mind. The additional complexity might not be worth it.
I won’t bore you with the basics of Vuex ORM because you can read all about them in the documentation. However, I will show you how I’m currently using it and which features have proved to be really useful.
The whole plugin is really simple to set up. The only additional thing I had to think about is JSON:API. It turned out that it wasn’t so difficult because the community around Vuex ORM was busy with making all sorts of additional features for Vuex ORM. I have used a JSON:API normalizing library that was compatible with Vuex ORM. I have opted out of using their Axios plugin because I needed more control over the data I was receiving. So, in the response interceptor, I added the JSON:API normalizer.
import JsonApiResponseConverter from 'json-api-response-converter';
.
.
.
appAxios.interceptors.response.use(async (response) => {
if (response.headers['content-type'] &&
response.headers['content-type'].includes('application/vnd.api+json')) {
response.data = new JsonApiResponseConverter(response.data).formattedResponse;
}
return response;
});
That was pretty much it. Now I could go on and create my models and actually use the library.
After I’ve written a few models, I realized that I was creating a non-orthogonal system. If I’ve wanted to switch some parts of the application in the future, it would prove to be a nearly impossible task. That’s why I’ve decided to separate the concerns in my application and make less of a rigid structure. This is what I came up with and what I’m currently using.
The folder structure
├── src/
│ ├── API/ - contains the files that handle API calls
│ ├── models/ - contains the files that define the ORM models
│ ├── repositories/ - contains the files that act like getters for the ORM
All of this could have been written inside the ORM model, but I found out that the files tend to grow a lot and the code gets a bit messy. You will see my point in the examples.
Example
models/OfferItem.ts
export default class OfferItem extends Model {
public static entity = 'offerItem';
// defines all of the fields and relationships on a model
public static fields() {
return {
id: this.attr(null),
formData: this.attr([]),
offerItemType: this.string(''),
price: this.number(''),
priceDetails: this.attr([]),
priceDate: this.string(''),
createdAt: this.string(''),
updatedAt: this.string(''),
offer_id: this.attr(null),
// simple inverse one-to-one relationship
product_id: this.attr(null),
product: this.belongsTo(Product, 'product_id'),
material_id: this.attr(null),
material: this.belongsTo(ProductCatalogue, 'material_id'),
offer: this.belongsTo(Offer, 'offer_id'),
};
}
// all of the methods that can be done with the model
// i.e. fetch all, search, delete, update, etc.
// we use the API layer here, not in the components
public static async getById(offerItemId: string) {
let offerItem;
try {
offerItem = await OfferItemAPI.getById(offerItemId);
} catch (e) {
return Promise.reject(e);
}
this.insertOrUpdate({
data: offerItem.data,
insertOrUpdate: ['product', 'offer'],
});
return Promise.resolve();
}
public static async updateExisting(
formData: ChecklistFieldEntry[],
offerItemId: string,
offerItemType: string) {
let offerItem;
try {
offerItem = await OfferItemAPI.updateExisting(
offerItemId,
formData,
offerItemType);
} catch (e) {
return Promise.reject(e);
}
this.insertOrUpdate({
data: offerItem.data,
insertOrUpdate: ['product', 'offer', 'material'],
});
return Promise.resolve();
}
}
api/OfferItemsAPI.ts
import OfferItem from '@/models/OfferItem';
export default class OfferItemAPI {
// makes the actual call to the back-end
public static async updateExisting(offerItemId: string, formData: ChecklistFieldEntry[], offerItemType: string) {
const request = {
data: {
type: 'offer_items',
id: offerItemId,
attributes: {
offerItemType,
formData,
},
},
};
let offerItem;
try {
offerItem =
await ApiController.patch(ApiRoutes.offerItem.updateExisting(offerItemId), request) as AxiosResponse;
} catch (e) {
return Promise.reject(e);
}
return Promise.resolve(offerItem);
}
public static async updateExistingMaterial(offerItemId: string, formData: ChecklistFieldEntry[]) {
const request = {
.
.
.
};
let offerItem;
try {
offerItem =
await ApiController.patch(ApiRoutes.offerItem.updateExisting(offerItemId), request) as AxiosResponse;
} catch (e) {
return Promise.reject(e);
}
return Promise.resolve(offerItem);
}
}
repositories/OfferItemsRepository.ts
import OfferItem from '@/models/OfferItem';
// using the query builder, we can easily get the specific data
// we need in our components
export default class OfferItemRepository {
public static getById(offerItemId: string) {
return OfferItem.query().whereId(offerItemId).withAll().first();
}
}
Even with a smaller example, you can see that the complexity would only grow with having everything in just one file.
The next step of this is to use it properly and keep the layers separate. The API layer is never used inside of a component, the component can only communicate with the model and the repository.
Concerns
Even though this has been a great help, I have run into some issues that have been bugging me.
Model interfaces
When you define a model and want to use the properties you set it, Typescript will argue that the properties you are using do not exist. I'm assuming this has to do with the facts they are nested in the "fields" property. Not a major issue, but you would have to write an additional interface to escape the errors.
json-api-response-converter
The library suggested by Vuex ORM has some issues when handling cyclical JSON. I have chosen to use jsona instead. The switch was relatively simple because of the way the libraries handle deserialization.
Conclusion
Even though there are some smaller nuances with the library I've run into, I would still urge you to try it out on your complex Vue projects. It's a great benefit to not worry about the data you have and just focus on the business logic of your application.
Top comments (2)
You can tackle this with interfaces as you suggested, but from my experience, defining an interface for every single model will eventually become awfully troublesome. :/
I found a couple of different approaches to this problem, but the only one that actually will work every time is declaring dynamic attributes on your classes with
[key: string]: any
.Let's take your example:
You could either add this definition to that class or, to avoid defining this in every Model extension, extend your own Model and define it there.
In the second example, you get all benefits of the ORM model, but you also get rid of the all property warnings that the transpiler founds.
There is also an approach with definite assignment assertions (typescriptlang.org/docs/handbook/r...). With them, you can say to the transpiler "Ok, I know that I have this, but you just can't find it due to some magic happening here, but don't worry, I know that I have this.".
With this, you will have to define every single field in entities beside listing them in the
static fields()
.Or you can use the combination of both. Finally, it boils down to what are you trying to solve: transpiler warnings or having a definition of attribute type. 😄
You're right! I've never thought of this before.
I like the second solution you provided, it is a lot like defining a new interface, but at least you don't have to create a new file each time.
The first solution is kind of defeating the purpose, but as you said, it's a matter of what you're solving.
Thanks for your input and I'll definitely utilize your solution!