Skip to content

Instantly share code, notes, and snippets.

@davismj
Last active February 9, 2021 15:20
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save davismj/a15213d0fa19411402338c9d31ab6897 to your computer and use it in GitHub Desktop.
Save davismj/a15213d0fa19411402338c9d31ab6897 to your computer and use it in GitHub Desktop.
Model Mapping best practices (notes)

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:

  1. Protect your enumerable properties
  2. Use factories, not constructors

Protect your enumerable properties

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:

services/fooService.js

class FooService {
    getAll() { 
      this.http.get()
        .then((items) => items.map(Foo.create));
    }
    
    save(foo) {
      this.http.post('', JSON.stringify(foo));
    }
}

models/foo.js

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:

pages/foo.js

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.
}

pages/foo.html

<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.

models/foo.js

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
    });
  }
}

Always use factories to construct your models

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.

models/foo.js

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

Notes

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment