I've identified two patterns that I believe to be best practices. First, always protect the enumerable properties of your models. Next, always use factories to construct your models. These are not specifically related to model mapping, but these strategies will significantly reduce the complexity of mapping models and may even eliminate the need for special handling in many cases.
Best Practices:
- Protect your enumerable properties
- Use factories, not constructors
First, some terminology. The words "view-model" in Aurelia is one component of the MVVM pattern, also known as the MVC pattern. In this pattern, you would have a a view that describes how everything should be displayed, a view-model that captures state and behavior of the view, and a model that provides data that is displayed and modified in the view. The view tends to be clear, but the line between view and view-model can be tricky. In general, your model should always represent the data from the database for that object identically. Any other properties should be considered state values, and all state values belong on the controller. Again, if it's not going to get saved to the database, it doesn't belong on your model (and it probably belongs on your view-model). I call this protecting your enumerable properties, since every enumerable property on your model corresponds with data in your database in the format it was handed to you. Following this pattern will make many things fall nicely into place.
This pattern starts with the assumption that whatever data you receive from your API would be valid to return to your API with or without modification. If we can make sure that every change we make to the data does not change the structure of the data, then any modification would also be valid to return to your API. If this is the case, we might have the following code in our application:
class FooService {
getAll() {
this.http.get()
.then((items) => items.map(Foo.create));
}
save(foo) {
this.http.post('', JSON.stringify(foo));
}
}
export class Foo {
static create(obj) {
const foo = new Foo();
Object.assign(foo, obj); // accept all incoming properties
return foo;
}
}
Very simple. The trick to getting this to work is to make sure that nowhere in code do we add any enumerable properties. In other words, we must never do something like this:
class FooViewModel {
select(foo) {
// foo.selected = true; // NEVER do this, it pollutes the model!
this.selected = foo; // Store your state variables on the view-model itself.
}
selectMany(foos) {
// foos.forEach((foo) = foo.selected = true); // NEVER do this, it pollutes the model!
this.selectedList = [...foos]; // Store your state variables on the view-model itself.
}
<template>
<!-- I use selected and selected list to demonstrate how they both can be implemented without touching the model. -->
<div repeat.for="foo of foos" class="${selected === foo ? 'selected' : ''}">
<input type="checkbox" checked.bind="selectedList" model.bind="foo" />
<span>${foo.name}</span>
</div>
</template>
If you've tried everything and your only choice is to add a property to Foo
then add a non-enumerable property. This will prevent the property from being serialized by JSON.stringify
which keeps our initial example valid. This is very rarely if ever needed; it can nearly always be avoided using the above strategies.
Foo {
static create(obj) {
const foo = new Foo();
Object.assign(foo, obj); // accept all properties from the server
return foo;
}
constructor() {
// Foo now has a "special" property but JSON.stringify will not serialize it.
Object.defineProperty(this, 'special', {
enumerable: false
});
}
}
So, we've seen how protecting our enumerable properties can eliminate the need for any special mapping from server data to client objects when dealing with simple data structures. If a class has a relationship to another class, we inevitably need to write some code to map that relationship. Using factories will keep this task very simple.
In the code examples above, we were already using factories in the form of Foo.create
. By using this strategy, we can add all the relationship mapping code to this factory function. As relationships change, we can make minimal changes to handle the changes. Let's say we want to add a Bar
child class to Foo
.
import { Bar } from 'models/bar';
export class Foo {
// we store the relationship mappings as a static property of the Foo class
static relationships = new Map([
['bar', Bar]
])
static create(obj) {
const foo = new Foo();
// for each property of the incoming object
for (let prop in obj) {
// grab the incoming value
let value = obj[prop];
// if this property is a mapped relationship
if (this.relationships.has(prop)) {
// grab the factory function for the relationship
let bar = this.relationships.get(prop);
// if the incoming value is an array
if (Array.isArray(value) {
// map each item to the factory function
foo[prop] = value.map((item) => bar.create(item));
// otherwise, map the single item to the factory function
} else {
foo[prop] = bar.create(value);
}
// if not a relationship, simply set the value
} else {
foo[prop] = value;
}
}
return foo;
}
}
By using factory functions everywhere, we can generalize this code to isolate the moving parts--the relationships--and abstract away the mapping. Provided that we protect our enumerable properties, then both Foo
and Bar
can be serialized using JSON.stringify
. When we're done, our class might look like this:
export class Foo extends Model {
static relationships = new Map([
['bar', Bar] // map items in the incoming "bar" property to the "Bar" class
]);
}
I've built a complete running example here: https://gist.run/?id=073595343a3061ece22087b913d6323d
For brevity, I've tucked the factory function in an inherited class Model
. This in itself is a small pitfall, and you should use a composition pattern over an inheritance pattern for this problem. A better implementation would be to create a Factory
base class and have FooFactory
inherit from Factory
, overriding the relationships as above. Additionally, this would give the freedom to skip using classes for your models if desired.