Modeling Domain Entities with Universal JavaScript

Entities are the crown jewels of any application. All of those thousands of lines of client and server code have but one purpose: To persist, present, and allow the user to manipulate instances of domain model entities.

Give your entities the respect they deserve. Once you’ve worked out what they are for your domain, building and testing them thoroughly should be your next goal.

Spoiler alert: There is no One True Way to implement your entities.

There are a number of takes out there on how to represent an application’s domain model in JavaScript. Most come from other languages like Java, since JavaScript, while maturing more every year, is still relatively late to the enterprise-level application architecture party.

Much of JavaScript’s history has been strictly client-side. In those days, there was rarely a need to be more elaborate than creating an Object with { and } and setting some properties on it. But client architecture has grown more complex, and latter-day approaches tend to resemble either EJB Entities, or the Value Object pattern. If you’re interested, here is a good article on the distinctions between the two.

If you’ve seen all the other approaches and just want to dive into a simple implementation that will work on Node or in the browser, is framework-agnostic and dependency-free, then by all means, take a gander at the js-entity-modeling GitHub repo. Otherwise, read on to see what my driving concerns were.

Avoiding Duplication of Effort

The most elaborate modeling and validation of the application’s domain entities usually takes place on the server, to ensure database integrity. But validation should always be done on the client too, so as to avoid unnecessary conversations with the server. In the past it was just a given that in-browser validation would be an unavoidable duplication of the server-side effort, which was usually implemented in some other language.

Then along came Node, presenting the opportunity for server-side JavaScript. This is a great stack, since mental friction is reduced when working on different sides of the codebase if you don’t have to switch languages and tools. Nor should you have to work with different representations of the same domain. To anyone working with this sweet setup, such a duplication of effort should be appalling.

This article outlines an approach that is driven by the desire to ensure that all code in such a system handles entities in the same way with regard to construction, validation, and marshalling by packaging the entities as a separate library, written in framework-agnostic, Universal JavaScript. It may or may not be the most elegant approach for your system’s architecture, depending on the frameworks you use and how prescriptive they are.

Modeling the Domain

Those who employ XML as a means of data exchange often use XML schema definitions as a way of describing the entities that their XML will represent. The beauty of this is that middleware can generate the code to represent those entities, and the XML can easily be validated against the schema. Further, you can produce diagrams that make it far easier to reason about the domain than the schema definition alone.

If you use JSON, schemas can be used as well, but are not as prevalent, probably because (currently) the only good editor is Altova’s XMLSpy 2017, which costs a thousand bucks and only runs on Windows. But with it, you can define your domain graphically, without worrying about the underlying syntax. Here’s an example:

User Entity Diagram

That’s certainly a lot easier to take in than the corresponding JSON schema:

{
  "$schema": "http://json-schema.org/draft-04/schema#",
  "description": "Schema for js-entity-modeling example",
  "definitions": {
    "user": {
      "description": "Private User Data",
      "type": "object",
      "properties": {
        "uid": {
          "description": "User ID",
          "type": "string"
        },
        "name": {
          "$ref": "#/definitions/name_token"
        },
        "email": {
          "description": "User's Email Address",
          "type": "string",
          "format": "email"
        },
        "address": {
          "$ref": "#/definitions/location"
        }
      },
      "required": [
        "uid",
        "name",
        "email",
        "address"
      ],
      "additionalProperties": false
    },
    "location": {
      "description": "Location Descriptor",
      "type": "object",
      "properties": {
        "address": {
          "description": "Street Address",
          "type": "string"
        },
        "city": {
          "description": "City",
          "type": "string"
        },
        "state": {
          "description": "State",
          "type": "string"
        },
        "postcode": {
          "description": "Postal Code",
          "type": "string"
        },
        "country": {
          "description": "Country",
          "type": "string"
        },
        "latitude": {
          "description": "Latitude",
          "type": "number"
        },
        "longitude": {
          "description": "Longitude",
          "type": "number"
        },
        "neigborhood": {
          "description": "Neighborhood Name",
          "type": "string"
        }
      },
      "required": [
        "address",
        "city",
        "state",
        "postcode",
        "country",
        "latitude"
      ],
      "additionalProperties": false
    },
    "user_token": {
      "description": "Token Value to Represent a User",
      "type": "object",
      "properties": {
        "uid": {
          "description": "User's ID",
          "type": "string"
        },
        "name": {
          "$ref": "#/definitions/name_token"
        },
        "photo": {
          "description": "User Photo",
          "type": "string",
          "format": "uri"
        }
      },
      "required": [
        "uid",
        "name"
      ],
      "additionalProperties": false
    },
    "name_token": {
      "description": "Token Value to Represent a User Name",
      "type": "object",
      "properties": {
        "display": {
          "description": "Display Name",
          "type": "string"
        },
        "first": {
          "description": "Optional First Name",
          "type": "string"
        },
        "last": {
          "description": "Optional Last Name",
          "type": "string"
        }
      },
      "required": [
        "display"
      ],
      "additionalProperties": false
    }
  }
}

Schema diagrams are useful for understanding and reasoning about complex domain models. The schemas themselves can be used to generate entity representations. But if you don’t have a nice schema editor and/or middleware to generate entity code and/or a system to support validation against a schema, you can just sketch things out on a whiteboard or on index cards until you’re reasonably happy with the model. It’s best to have the entities figured out before you start slinging the code to represent them.

Representing Entities in Code

We need the ability to construct an object with the decided-upon properties using the new keyword or by marshalling from a plain object. Instances should inherit methods from the prototype that can tell you if the object is valid, and marshall it to a plain JavaScript object.

There is a school of thought that says you shouldn’t expose the properties but should instead only mutate them by way of behavioral methods. It diverges somewhat from my goal of sharing validation code across the stack, and focuses instead on making sure the entity cannot get into an invalid state, since domain logic encoded in the behavior methods will always do the right thing with it. Here’s a good take on that approach. It isn’t wholly incompatible with this one, but it is more client-focused.

/**
 * js-entity-modeling - user.js
 * Schema Entity: user
 */
(function() {

    // Support Node and browser with selective export to modules or window
    var User = (function() {

        /**
         * Constructor
         * @param uid
         * @param email
         * @param display
         * @param first
         * @param last
         * @param photo_url
         * @param address
         * @constructor
         */
        function User(uid, email, display, first, last, photo_url, address) {
            this.uid = uid;
            this.email = email;
            this.name = new NameToken(display, first, last);
            this.photo_url = photo_url;
            this.address = address; /* Location instance */
        };

        /**
         * Get a new User instance from a database representation
         * @param o
         * @returns {User}
         */
        User.fromObject = function(o) {
            var address = (o.address) ? new Location.fromObject(o.address) : null;
            return new User(o.uid, o.email, o.name.display, o.name.first, o.name.last, o.photo_url, address);
        };

        /**
         * Get a database representation of this User instance
         * @returns {object}
         */
        User.prototype.toObject = function(){
            return JSON.parse(JSON.stringify(this));
        };

        /**
         * Get a string representation of this User instance
         * @returns {boolean}
         */
        User.prototype.toString = function() {
            return [
                this.uid,
                this.email,
                this.name.toString(),
                this.address ? this.address.toString() : "",
                this.photo_url ? this.photo_url : ""
            ].join(', ');
        };

        /**
         * Get a UserToken instance referring to this User
         * @returns {UserToken}
         */
        User.prototype.getToken = function() {
            return new UserToken( this.uid, this.name, this.photo_url );
        };

        /**
         * Is this User instance's uid field valid?
         * @returns {boolean}
         */
        User.prototype.uidIsValid = function() {
            var valid = false;
            try {
                valid = (
                    typeof this.uid !== 'undefined' &&
                    this.uid !== null
                );
            } catch (e) {}
            return valid;
        };

        /**
         * Is this User instance's email field valid?
         * @returns {boolean}
         */
        User.prototype.emailIsValid = function() {
            var valid = false;
            try {
                valid = (
                    typeof this.email !== 'undefined' && this.email !== null &&
                    /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/.test(this.email)
                );
            } catch (e) {}
            return valid;
        };

        /**
         * Is this User instance's name field valid?
         * @returns {boolean}
         */
        User.prototype.nameIsValid = function() {
            var valid = false;
            try {
                valid = (
                    typeof this.name !== 'undefined' && this.name !== null &&
                    Object.getPrototypeOf( this.name ) === NameToken.prototype &&
                    this.name.isValid()
                );
            } catch (e) {}
            return valid;
        };

        /**
         * Is this User instance valid?
         * @returns {boolean}
         */
        User.prototype.isValid = function() {
            return (
                this.uidIsValid() &&
                this.emailIsValid() &&
                this.nameIsValid()
            );
        };

        return User;

    })();

    // Export
    if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
        module.exports = User;
    } else {
        window.User = User;
    }

})();

The definition of the entity is wrapped in an IIFE, and set on a variable that is then set on module.exports or window.User, depending upon the environment we’re running in; Node or the browser. Then the entire business is wrapped in another IIFE, so that the only trace of this code after it has run is what we specifically placed in the global namespace. Tip o’ the old propeller beanie to Matteo Agosti for this part of the solution.

NOTE: In order to avoid conflict with other code in the browser, you might want to further namespace your entities (e.g., window.MyApp.User) instead of sticking them directly into the global namespace. Any code creating a User instance would have to use new MyApp.User() instead of new User(). For this example, I’m doing the simplest possible thing that works in both environments.

Also, notice there are validation methods at both field and instance level. Each field may have different validation rules, such as the email field which has a regex test in addition to making sure it’s populated. And the name field must be a valid instance of NameToken. This allows individual fields on a form to use that validation logic as the form is filled in or modified. The instance-level validation method simply makes sure all the required fields are valid, using calls to the field-level methods. A form could use the instance’s isValid method to control whether the submit button is enabled, while the server uses that same method to make sure the object that was submitted is valid before writing it to the database.

Loading the Entity Prototypes

Now that we have our entities built, we need an easy way of loading them all at once into the browser, or as a module in Node.

/**
 * js-entity-modeling - domain.js
 * Domain Entities
 *
 * Supports usage in both Node.js and browser contexts.
 *
 * @author Cliff Hall <cliff@futurescale.com>
 */
(function() {

    const NODE = (typeof module !== 'undefined' && typeof global !== 'undefined');
    const path = './entity/';
    const entities = [
        {name: 'Location', file:'location'},
        {name: 'NameToken', file:'name-token'},
        {name: 'User', file:'user'},
        {name: 'UserToken', file:'user-token'}
    ];

    if (NODE) { // In Node, add the constructors to global

        entities.forEach( (entity) => global[entity.name] = require(path + entity.file) );

    } else if (document) {  // In browser, load files with script tags in document.head

        entities.forEach( (entity) => {
            var script = document.createElement('script');
            script.type = 'text/javascript';
            script.src = path + entity.file + ".js";
            document.getElementsByTagName('head')[0].appendChild(script);
        });

    }

})();

Testing

Here’s a quick demonstration that shows how easy it is to use our new entities in the browser:

<html>
<head>
    <script src="domain.js"></script>
</head>
<body onload="testUser()">
<script>

    /**
     * Show that a User entity can be created.
     */
    function testUser() {
        let user = new User(
                '1234',
                'test@example.com',
                'Testy',
                'Tester',
                'TestMan'
        );

        alert(user.toString());
    }
</script>

</body>
</html>

And on the Node side, here is a more thorough Jasmine test:

/**
 * js-entity-modeling
 * Jasmine Test for Schema Entity: user
 */
require('../domain');

describe( "A valid User entity", () => {

    it( "can be created with minimal constructor inputs", () => {

        const UID = '1234';
        const EMAIL = 'test@example.com';
        const DISPLAY = 'TestMan';

        let user = new User(UID, EMAIL, DISPLAY);

        expect( user ).not.toBeUndefined();
        expect( user.uid ).toBe( UID );
        expect( user.email ).toBe( EMAIL );
        expect( Object.getPrototypeOf( user.name ) ).toBe( NameToken.prototype );
        expect( user.name.isValid() ).toBe( true );
        expect( user.name ).not.toBeUndefined();
        expect( user.name.display ).toBe( DISPLAY );
        expect( user.isValid() ).toBe( true );

    });

    it( "can be created with complete constructor inputs", () => {

        const UID = '1234';
        const EMAIL = 'test@example.com';
        const DISPLAY = 'TestMan';
        const FIRST = 'Testy';
        const LAST = 'Tester';
        const PHOTO_URL = 'https://example.com/images/image.jpg';
        const BUDGET = 25.00;
        const ADDRESS = new Location(
            "214 Royal St",
            "New Orleans",
            "LA",
            "70130",
            "United States",
            29.954163,
            -90.0702177,
            "French Quarter"
        );

        let user = new User( UID, EMAIL, DISPLAY, FIRST, LAST, PHOTO_URL, ADDRESS, BUDGET );

        expect( user ).not.toBeUndefined();
        expect( user.uid ).toBe( UID );
        expect( user.email ).toBe( EMAIL );
        expect( user.name ).not.toBeUndefined();
        expect( Object.getPrototypeOf( user.name ) ).toBe( NameToken.prototype );
        expect( user.name.isValid() ).toBe( true );
        expect( user.name.first ).toBe( FIRST );
        expect( user.name.last ).toBe( LAST );
        expect( user.name.display ).toBe( DISPLAY );
        expect( user.photo_url ).toBe( PHOTO_URL );
        expect( user.address ).not.toBeUndefined();
        expect( user.address.address ).toBe( ADDRESS.address );
        expect( user.address.city ).toBe( ADDRESS.city );
        expect( user.address.state ).toBe( ADDRESS.state );
        expect( user.address.postcode ).toBe( ADDRESS.postcode );
        expect( user.address.country ).toBe( ADDRESS.country );
        expect( user.address.latitude ).toBe( ADDRESS.latitude );
        expect( user.address.longitude ).toBe( ADDRESS.longitude );
        expect( user.address.neighborhood ).toBe( ADDRESS.neighborhood );
        expect( Object.getPrototypeOf( user.address ) ).toBe( Location.prototype );
        expect( user.address.isValid() ).toBe( true );
        expect( user.isValid() ).toBe( true );

    });

    it( "can be created with a plain object using fromObject()", () => {

        const UID = '1234';
        const EMAIL = 'test@example.com';
        const DISPLAY = 'TestMan';
        const FIRST = 'Testy';
        const LAST = 'Tester';
        const PHOTO_URL = 'https://example.com/images/image.jpg';
        const BUDGET = 25.00;
        const ADDRESS = {
            address: "214 Royal St",
            city: "New Orleans",
            state: "LA",
            postcode: "70130",
            country: "United States",
            latitude: 29.954163,
            longitude: -90.0702177,
            neighborhood: "French Quarter"
        };

        const INPUT = {
            uid: UID,
            email: EMAIL,
            name: {
                display: DISPLAY,
                first: FIRST,
                last: LAST
            },
            photo_url: PHOTO_URL,
            address: ADDRESS
        };

        let user = User.fromObject( INPUT );

        expect( user ).not.toBeUndefined();
        expect( user.uid ).toBe( UID );
        expect( user.email ).toBe( EMAIL );
        expect( user.name ).not.toBeUndefined();
        expect( Object.getPrototypeOf( user.name ) ).toBe( NameToken.prototype );
        expect( user.name.isValid() ).toBe( true );
        expect( user.name.first ).toBe( FIRST );
        expect( user.name.last ).toBe( LAST );
        expect( user.photo_url ).toBe( PHOTO_URL );
        expect( user.address ).not.toBeUndefined();
        expect( Object.getPrototypeOf( user.address ) ).toBe( Location.prototype );
        expect( user.address.isValid() ).toBe( true );
        expect( user.address.address ).toBe( ADDRESS.address );
        expect( user.address.city ).toBe( ADDRESS.city );
        expect( user.address.state ).toBe( ADDRESS.state );
        expect( user.address.postcode ).toBe( ADDRESS.postcode );
        expect( user.address.country ).toBe( ADDRESS.country );
        expect( user.address.latitude ).toBe( ADDRESS.latitude );
        expect( user.address.longitude ).toBe( ADDRESS.longitude );
        expect( user.address.neighborhood ).toBe( ADDRESS.neighborhood );
        expect( user.isValid() ).toBe( true );

    });

    it( "can be created with a plain object that was created using toObject()", () => {

        const UID = '1234';
        const EMAIL = 'test@example.com';
        const DISPLAY = 'TestMan';
        const FIRST = 'Testy';
        const LAST = 'Tester';
        const PHOTO_URL = 'https://example.com/images/image.jpg';
        const BUDGET = 25.00;
        const ADDRESS = new Location(
            "214 Royal St",
            "New Orleans",
            "LA",
            "70130",
            "United States",
            29.954163,
            -90.0702177,
            "French Quarter"
        );

        let obj = new User(  UID, EMAIL, DISPLAY, FIRST, LAST, PHOTO_URL, ADDRESS, BUDGET ).toObject();
        let user = User.fromObject( obj );

        expect( user ).not.toBeUndefined();
        expect( user.uid ).toBe( UID );
        expect( user.email ).toBe( EMAIL );
        expect( user.name ).not.toBeUndefined();
        expect( Object.getPrototypeOf( user.name ) ).toBe( NameToken.prototype );
        expect( user.name.isValid() ).toBe( true );
        expect( user.name.first ).toBe( FIRST );
        expect( user.name.last ).toBe( LAST );
        expect( user.name.display ).toBe( DISPLAY );
        expect( user.photo_url ).toBe( PHOTO_URL );
        expect( user.address ).not.toBeUndefined();
        expect( user.address.address ).toBe( ADDRESS.address );
        expect( user.address.city ).toBe( ADDRESS.city );
        expect( user.address.state ).toBe( ADDRESS.state );
        expect( user.address.postcode ).toBe( ADDRESS.postcode );
        expect( user.address.country ).toBe( ADDRESS.country );
        expect( user.address.latitude ).toBe( ADDRESS.latitude );
        expect( user.address.longitude ).toBe( ADDRESS.longitude );
        expect( user.address.neighborhood ).toBe( ADDRESS.neighborhood );
        expect( Object.getPrototypeOf( user.address ) ).toBe( Location.prototype );
        expect( user.address.isValid() ).toBe( true );
        expect( user.isValid() ).toBe( true );

    });

    it( "isn't valid unless it has a uid, email, and a display name", () => {

        const UID = '1234';
        const EMAIL = 'test@example.com';
        const DISPLAY = 'TestMan';

        let user = new User();

        expect( user ).not.toBeUndefined();
        expect( user.uid ).toBeUndefined();
        expect( user.email ).toBeUndefined();
        expect( Object.getPrototypeOf( user.name ) ).toBe( NameToken.prototype );
        expect( user.name ).not.toBeUndefined();
        expect( user.name.display ).toBeUndefined();
        expect( user.name.isValid() ).toBe( false );
        expect( user.isValid() ).toBe( false );

        user.uid = UID;
        expect( user.isValid() ).toBe( false );

        user.email = EMAIL;
        expect( user.isValid() ).toBe( false );

        user.name.display = DISPLAY;
        expect( user.isValid() ).toBe( true );

    });

    it( "can generate a valid UserToken to represent the user in other contexts", () => {

        const UID = '1234';
        const EMAIL = 'test@example.com';
        const DISPLAY = 'TestMan';
        const FIRST = 'Testy';
        const LAST = 'Tester';
        const PHOTO_URL = 'https://example.com/images/image.jpg';

        let user = new User( UID, EMAIL, DISPLAY, FIRST, LAST, PHOTO_URL );
        let token = user.getToken();

        expect( token ).not.toBeUndefined();
        expect( Object.getPrototypeOf( token ) ).toBe( UserToken.prototype );
        expect( token.isValid() ).toBe( true );
        expect( token.uid ).toBe( UID );
        expect( token.photo_url ).toBe( PHOTO_URL );
        expect( Object.getPrototypeOf( token.name ) ).toBe( NameToken.prototype );
        expect( token.name.isValid() ).toBe( true );
        expect( token.name.first ).toBe( FIRST );
        expect( token.name.last ).toBe( LAST );
        expect( token.name.display ).toBe( DISPLAY );

    });

    it( "is not valid with an invalid email address", () => {

        const UID = '1234';
        const BAD_EMAIL = 'test';
        const GOOD_EMAIL = 'test@example.com';
        const DISPLAY = 'TestMan';

        let user1 = new User(UID, BAD_EMAIL, DISPLAY);
        expect( user1 ).not.toBeUndefined();
        expect( user1.email ).toBe( BAD_EMAIL );
        expect( user1.isValid() ).toBe( false )

        let user2 = new User(UID, GOOD_EMAIL, DISPLAY);
        expect( user2 ).not.toBeUndefined();
        expect( user2.email ).toBe( GOOD_EMAIL );
        expect( user2.isValid() ).toBe( true );

    });
});

Conclusion

The above solution is meant to solve the problem of duplicated validation and marshalling code in an environment where JavaScript is running on both client and server by placing that code on the entity representation itself. It is a framework-agnostic example with no dependencies.

Before choosing your own implementation, consider how your domain model entities will be used rather than taking any one person’s advice. Their approach may be driven by pressures that are not your own.

For instance, with Angular on the client, you might want to use the properties of an entity instance in data-binding expressions on a form, then export a raw object (if the entity is valid) to send to the server. When the server sends you a raw object, you can easily marshall it into an entity instance. If so, the above solution might be a good place for you to start.

React aficionados however, will likely recoil at the idea of directly mutable properties on an object meant to represent an entity, and might be more interested in a framework-specific solution like react-entity, particularly if they are using React on the server, too.

Regardless of how apropos the solution is to your situation, I hope this article helps you think it through, so that your domain model entities will shine like the crown jewels they are.

Clone the js-entity-modeling GitHub repo and get started now!

Leave a Reply

Your email address will not be published. Required fields are marked *