App Architecture with Vue

Kyle Coberly

www.kylecoberly.com

Kyle Coberly

  • Educator
  • Business Dork
  • Software Developer

The Goal:

Make you want to try this pattern

Context Diagram

Application

Inputs

Outputs

Component

Component

Component

The Problem

Application

Inputs

Outputs

Component

Component

Component

The Problem

Well-architected applications are maintainable

The primary driver of maintainability is testability

The primary driver of testability is purity

Application Architecture

Where does the complexity go?

Model-View-Component

Model-View-Component

  • Components
    • Encapsulate related presentation & behavior
    • Prefer emitters over closures
    • Use services for application-wide state
  • Views
    • Communicate with the store through views
    • Routes represent a saved application state
    • Nested views, nested routes
  • Models
    • Organize the store with modules
    • Normalize data operations with models
    • Isolate network requests with adapters & serializers

Components

Encapsulation

Components

Component

Component

Component

Component

Components - Encapsulation

<header>
    <h1><a href="/"><img src="" alt="Logo" /></a><h1>
    <nav>
        <ul>
            <li><router-link :to="{name: 'products'}">Products</router-link></li>
            <li><router-link :to="{name: 'team'}">Team</router-link></li>
        </ul>
    </nav>
</header>

<main>
    <h2>Our Products</h2>
    <p>Lorem ipsum dolor sit amet, consectetur adipiscing</p>
    <some-widget />
    <another-widget>
        <some-nested-widget />
    </another-widget>
</main>

<footer>
    <nav>
        <ul>
            <li><router-link :to="{name: 'products'}">Products</router-link></li>
            <li><router-link :to="{name: 'team'}">Team</router-link></li>
            <li><router-link :to="{name: 'about'}">About</router-link></li>
            <li><router-link :to="{name: 'investors'}">Investors</router-link></li>
            <li><router-link :to="{name: 'careers'}">Careers</router-link></li>
        </ul>
    </nav>
    <small>© 2008 BarkWire</small>
</footer>

Components - Encapsulation

<application-header />
<router-view />
<application-footer />
<header>
    <h1><a href="/"><img src="" alt="Logo" /></a><h1>
    <nav>
        <ul>
            <li><router-link :to="{name: 'products'}">Products</router-link></li>
            <li><router-link :to="{name: 'team'}">Team</router-link></li>
        </ul>
    </nav>
</header>

What's the API for the component?

Emitters vs. Closures

Components

Parent

Child

State

Events

Parent

Child

State

Functions

Components - Emitters vs. Closures

<template>
    <counter
        :count="count"
        @increment="increment"
    />
</template>

<script>
    export default {
        components: {
            counter
        },
        data(){
            return {
                count: 1
            };
        },
        methods: {
            increment(){
                this.count++;
            }
        }
    };
</script>
<template>
    <p>{{count}}</p>
    <button @click="@emit('increment')"></button>
</template>

<script>
    export default {
        props: {
            count: Number
        }
    };
</script>

Emitters

Components - Emitters vs. Closures

describe("Outer Component", function(){
    describe("#increment", () => {
        it("increments", () => {
            const component = mount(OuterComponent)
            assert.equal(component.vm.count, 1)
            component.vm.increment()
            assert.equal(component.vm.count, 2)
        })
    });
});

Testing Emitters

describe("Counter", function(){
    describe("render", () => {
        it("shows the current count", () => {
            const component = mount(Counter, {
                propsData: {count: 2}
            });
            assert.equal(component.text(), "2")
        });
    });
    describe("#increment", () => {
        it("increments", () => {
            const component = mount(Counter);
            component.find("button").trigger("click");
            assert.ok(component.emitted().increment);
        });
    });
});

Components - Emitters vs. Closures

<template>
    <counter
        :count="count"
        :increment="increment"
    />
</template>

<script>
    export default {
        components: {
            counter
        },
        data(){
            return {
                count: 1
            };
        },
        methods: {
            increment(){
                this.count++;
            }
        }
    };
</script>
<template>
    <p>{{count}}</p>
    <button @click="increment"></button>
</template>

<script>
    export default {
        props: {
            count: Number,
            increment: Function
        }
    };
</script>

Closures

Components - Emitters vs. Closures

describe("Outer Component", function(){
    describe("#increment", () => {
        it("increments", () => {
            const component = mount(OuterComponent)
            assert.equal(component.vm.count, 1)
            component.vm.increment()
            assert.equal(component.vm.count, 2)
        })
    });
});

Testing Closures

describe("Counter", function(){
    describe("#increment", () => {
        it("increments", () => {
            const incrementStub = () => 2;
            const component = mount(Counter, {
                propsData: {
                    count: 1,
                    increment: incrementStub
                }
            })
            assert.equal(component.text(), "1");
            component.find("button").trigger("click");
            assert.equal(component.text(), "2");
        })
    });
});

Components - Emitters vs. Closures

Emitting results in cleaner tests

it("increments", () => {
  const component = mount(Counter);
  component.find("button").trigger("click");
  assert.ok(component.emitted().increment);
});
<counter
  :count="count"
  @increment="increment"
/>
<counter
  :count="count"
  :increment="increment"
/>
it("increments", () => {
  const incrementStub = () => 2;
  const component = mount(Counter, {
    propsData: {
      count: 1,
      increment: incrementStub
    }
  });
  assert.equal(component.text(), "1");
  component.find("button").trigger("click");
  assert.equal(component.text(), "2");
});

Services

Components

Parent

Child

State

Events

Service

State

Events

Services = Globals

Components - Services

  • Use sparingly
  • Inject into component tests
  • Clearly identify service dependencies

Components - Services

<template>
    <form @submit="sendMessage">
        <div v-if="loadingService.isLoading">
            <loading-spinner />
        </div>
    </form>
</template>
<script>
    export default {
        data: () => ({isLoading: this.$store.services.loading.isLoading});
    };
</script>
<template>
    <button @click="toggleMode">Toggle Mode</button>
</template>
<script>
    export default {
        methods: {
            toggleMode(){
                this.$store.services.mode.dispatch("toggleMode");
            }
        }
    };
</script>

Consuming Services - Examples

Components - Services

import Vue from "vue";
import Vuex from "vuex";
import loadingService from "../../src/services/loading";
Vue.use(Vuex);

describe("Form", function(){
    describe("loading spinner", () => {
        beforeEach(){
            this.store = new Vuex.Store();
            this.store.registerModule(["services", "loading"], loadingService);
            this.component = mount(Form, {store: this.store});
        }
        it("doesn't display when data isn't loading", () => {
            this.component.$store.services.loading.state.isLoading = false;
            assert.ok(this.component.find(".loading-spinner").exists());
        });
        it("does display when data is loading", () => {
            this.component.$store.services.loading.state.isLoading = true;
            assert.ok(this.component.find(".loading-spinner").exists());
        });
    });
});

Testing a component with a service

Views

Nested Views, Nested Routes

Views

App.vue

DogList.vue

DogDetail.vue

Views - Nested Views, Nested Routes

Barkwire

  • Bixby
  • Mesa
  • Panzer
  • Iago

Panzer is a Big Galoot!

Copyright 2018, Barkwire PBC

NESTED

Views - Nested Views, Nested Routes

Barkwire

Panzer is a Big Galoot!

Copyright 2018, Barkwire PBC

FLAT

Dogs > Panzer

Barkwire

Copyright 2018, Barkwire PBC

Dogs

  • Bixby
  • Mesa
  • Panzer
  • Iago

Views - Nested Views, Nested Routes

const router = new VueRouter({
    routes: [{
        path: "/dogs",
        component: DogsList,
        children: [{
            path: "",
            component: DogsListIndex
        },{
            path: ":id",
            component: DogDetail
        }
      ]
    }
  ]
});
const router = new VueRouter({
    routes: [{
        path: "/dogs",
        component: DogsList,
    },{
        path: "/dogs/:id",
        component: DogDetail
    }
  ]
});
https://barkwire.com/dogs
https://barkwire.com/dogs/1

Views - Nested Views, Nested Routes

App.vue

DogList.vue

DogDetail.vue

https://barkwire.com/dogs/1

All routes

All /dogs routes

All /dogs/:id routes

URL = State

Views

Barkwire

Panzer is a Big Galoot!

Copyright 2018, Barkwire PBC

Dogs > Panzer

Barkwire

Panzer is a Big Galoot!

Copyright 2018, Barkwire PBC

Dogs > Panzer

https://barkwire.com/dogs/1
https://barkwire.com/dogs/1

Why?

Views - URL = State

  • The app should have the same state after refresh
  • Necessary for sending links and bookmarking

Data Access

Views

Store

Actions

View

Component

State

Events

State

Why Here?

Views - Data Access

  • Since a URL represents a state and maps to a view...
  • And since our components should be dumb to increase testability...
  • Our views should be responsible for getting and setting application state

Views - Data Access

<template>
    <dog-profile
        dog="dog"
        updateDate="updateDog"
    />
</template>

<script>
    export {
        created(){
            return this.$store.dogs.dispatch("fetch", this.id);
        },
        computed: {
            id(){
                return this.$route.params.id;
            },
            dog(){
                return this.$store.dogs.getters.read(this.id);
            }
        },
        methods: {
            updateDog(dog){
                return this.$store.dogs.dispatch("update", dog);
            }
        }
    };
</script>

Views - Data Access

Advantages

  • Don't need to be directly tested
  • Dumb down your components
  • Creates a "seam" for debugging

Models

Modules

Models

Store

Data Module

Services Module

Service Module

Service Module

Actions

State

CRUD

Model

Data Module

Data Module

Rules

  • The store coordinates modules
  • Namespace all the services together
  • Only dispatch actions to modules
  • Defer implementation details to models

Models - Modules

Substores

Models - Modules

export default new Vuex.Store({
    state: {
        dogs: []
    },
    mutations: {
        setDogs(state, dogs){
            return state.dogs = dogs;
        },
        updateDog(state, updatedDog){
            let dog = state.dogs.find(dog => updatedDog.id);
            dog = updatedDog;
        }
    },
    actions: {
        async fetchDogs({commit}, dogs){
            const dogs = await Dog.fetchAll();
            return commit("setDogs", dogs);
        },
        async updateDog({commit}, dog){
            const dog = await dog.save();
            return commit("updateDog", dog);
        }
    }
});

Data Models

Models

Model

Store

Module

CRUD

Model

Why?

  • Separate your use of data from where/how it's stored
  • DRY your CRUD code
  • Normalize your data

Models - Data Models

Models - Data Models

class Dog {
    constructor(data){
        this.id = +data.id;     
        this.properties = ["name", "breed", "ownerId"]
        this.modelName = "dog";
        this.data = this.normalize(data);
    }
    normalize(data){
        return this.properties.reduce((modelData, property) => {
            modelData.push(data[property]);
            return data;
        }, []);
    }
    fetch(){}
    static fetchAll(){}
    destroy(){}
    save(){}
}

Building a model

Models - Data Models

class Model {
    constructor(data){
        this.id = +data.id;
    }
    normalize(data){
        return this.properties.reduce((modelData, property) => {
            modelData.push(data[property]);
            return data;
        }, []);
    }
    fetch(){}
    static fetchAll(){}
    destroy(){}
    save(){}
}

class Dog extends Model {
    constructor(data){
        super(data);
        this.properties = ["name", "breed", "ownerId"]
        this.modelName = "dog";
        this.data = this.normalize(data);
    }
}

Extracting Common Behavior

Models - Data Models

<template>
    <form @submit="addDog">
        <label>Name</label>
        <input name="name" :value="name" />
        <input type="submit" value="Add Dog" />
    </form>
</template>

<script>
    import Dog from "../models/dog";

    export default {
        methods: {
            addDog(){
                this.$emit("addDog", new Dog({
                    name: this.name
                }));
            }
        }
    };
</script>

Using Models to Create

Models - Data Models

import Dog from "../models/dog";

export default {
    state: {
        dogs: []
    },
    mutations: {
        setDogs(state, dogs){
            return state.dogs = dogs;
        }
    }
    actions: {
        async fetchAll({commit}){
            const dogs = Dog.fetchAll();
            return commit.setDogs(dogs);
        }
    }
}

Using Models to Retrieve

Models - Data Models

<template>
    <div>
        <h2>Dog Name</h2>
        <p>{{dog.data.name}}</p>
    </div>
</template>

<script>
    export default {
        props: {
            dog: Object
        }
    };
</script>

Using Model Data

Adapters & Serializers

Models

Adapter

Serializer

Deserializer

Model

API

The Adapter Pattern

Models - Adapters & Serializers

Adapter

Serializer

Deserializer

Model to Save

Saved Model

Internal Format

External Format

External Format

Internal Format

POST Request

POST Response

Serializers

export default {
    serialize(model){
        return JSON.stringify(model);
    },
    deserialize(data){
        return typeof data === "string"
            ? JSON.parse(data)
            : data;
    }
};

Models - Adapters & Serializers

Adapters

import Serializer from "./serializer";

class Adapter {
    constructor(modelName){
        this.serializer = Serializer;
        this.modelName = modelName;
        this.baseUrl = process.env.BASE_URL;
        this.buildUrl.bind(this);
        this.serialize.bind(this);
        this.deserialize.bind(this);
    }
    buildUrl(){
        return `${this.baseUrl}/${pluralize(this.modelName)}`;
    }
    serialize(model){
        return this.serializer.serialize(model);
    }
    deserialize(data){
        return this.serializer.deserialize(data);
    }
    save(model){
        return fetch(this.buildUrl(), {
            method: "POST",
            "Content-Type": "application/json",
            body: this.serialize(model)
        }).then(response => response.json())
        .then(response => this.serializer.deserialize(response));
    }
}

Models - Adapters & Serializers

Using an adapter in a model

import Adapter from "./adapter";

class Dog extends Model {
    constructor(data){
        super(data);
        this.adapter = new Adapter("dog");
    }
    save(){
        return this.adapter.save()
    }
}

Models - Adapters & Serializers

Keeps your modules clean!

{
    actions: {
        async save({commit}, dog){
            const dog = new Dog(dog);
            const savedDog = await dog.save();
            return commit("save", savedDog);
        }
    }
}

Models - Adapters & Serializers

Review

Model-View-Component

  • Components
    • Encapsulate related presentation & behavior
    • Prefer emitters over closures
    • Use services for application-wide state
  • Views
    • Communicate with the store through views
    • Routes represent a saved application state
    • Nested views, nested routes
  • Models
    • Organize the store with modules
    • Normalize data operations with models
    • Isolate network requests with adapters & serializers

Bonus!

Testing

  • Unit Test:
    • Serializers
    • Adapters (stub network)
    • Models
  • Integration Test:
    • Component methods, rendering & emitting
    • Services
  • E2E Test:
    • Happy/Sad Paths for features (stub network)
    • Happy/Sad Paths for features (real network)

Styling

  • Style components to be relatively "unthemed"
    • No thematic colors
    • Sizes should be highly responsive
  • Theme them in the view

Increment!

How much?

42

Increment!

How much?

42

Questions?

kylecoberly.com