Same Same but Different

At some point you come across that principal, where your application and your components are handling data which is similar but slighly off. Sometimes your data comes from different sources, or your application creates variations of existing data.

This concept is using a variation of Data Transfer Objects to map data back and forth. DTO's are a common way to aggregate all the required data for a service into an object with a predefined structure. It works like an interface for a method, but allows way more complex arguments.

This is not the best explanation of a DTO, but it's enough to understand this concept.

— I, need a better disclaimer for my thoughts

The issue with a naive apporch

This is a simple task from our imaginary task manager. The structure can be used to load and update that item on our API.

// todos.example.com/api/todo/19
{
    "id": 19,
    "title": "Request access to the cake factory",
    "priority": 3,
}

But what happens if we want to import data from a an other source?

// todone.example.com/tasks/29
{
    "taskID": 29,
    "name": "Request access to the cake factory",
    "priority": "high",
}

The data is similar enough to display them side by side, but the structure is different. You can not throw that data into your application without mapping it over to our structure.

The Quest

We want to be able to convert similar data to a structure our components can handle and export back to the old structure.

The Concept

The followiing class will convert, hold and transform our data from different sources. The following parts are used in this structured class.

Constructor defines the inner shape

class TodoBox {
    //...
    constructor() {
        this._id = null;
        this._title = null;
        this._priority = null;
    }
    //...
}

The constructor defines private properties and it's empty state.

Note: We are intentionally not using private class fields, since VueJS's Observer has trouble handling private fields (vuejs 2.*).

Create(data) is the import

class TodoBox {
    // Create a box from default data structure
    static create(data) {
        if (!data) return null;

        const box = new TodoBox();
        
        box.id = data.id;
        box.title = data.title;
        box.priority = PriorityBox.create(data.priority);

        return box;
    }
    //...

Our create method is responsible to setup a fresh object and convert the incomming data properly, so that we can use it within our class. We are using public setter for our properties, to ensure proper fallback values (more on that on the getters/setters section).

The combination of multiple boxes is what makes this concept powerful. We can create an other box based on our current creation path.

The point here is that we are controlling the transformation and fallback values.

Unfold is the export

class TodoBox {
    // Unfold a box to plain data
    unfold() {
        return {
            id: this.id,
            title: this.title,
            priority: this.priority?.unfold(),
        }
    }
    //...
}

Unfold will return the encapuslated data back to a regular object. Into exact the same structure which has been used by the create(data) method.

This leeds to a very simple clone mechanism, where cascading is generating a new version of your stored data.

// Clone the current instance
clone() {
    return TodoBox.create(this.unfold());
}

Using Getter and Setter

class TodoBox {
    //...
    get id() {
        return this._id;
    }

    set id(value) {
        this._id = value ?? null;
    }
    //...
}

Our getter and setter are controlling how other componets are interacting with our data. The setter is also responsible to provide a fallback value. The nullish coalescing operator ?? will use the value on the right side if null or undefined was provided.

This ensures that we control our unset/empty values.

static create(data) {
        if (!data) return null;
}

This is why our create function will return null on falsy values. A box is handling complex data most of the time, and primitive values are stored within a box.

Assembled parts leed the way

The following class is a base version of a box.

class TodoBox {
    constructor() {
        this._id = null;
        this._title = null;
        this._priority = null;
    }

    // Create a box from default data structure
    static create(data) {
        if (!data) return null;

        const box = new TodoBox();
        
        box.id = data.id;
        box.title = data.title;
        box.priority = PriorityBox.create(data.priority);

        return box;
    }

    // Unfold a box to plain data
    unfold() {
        return {
            id: this.id,
            title: this.title,
            priority: this.priority?.unfold(),
        }
    }

    // Clone the current instance
    clone() {
        return TodoBox.create(this.unfold());
    }

    // Getter and Setter below
    get id() {
        return this._id;
    }

    set id(value) {
        this._id = value ?? null;
    }
    
    // and so on...
}

The Solution

Our data flow works by creating a box out of data, using the box withing our components, and exporting them back to raw objects to send them to an API.

const taskBox = TaskBox.create({
    "id": 19,
    "title": "Request access to the cake factory",
    "priority": 3,
});

console.log(taskBox.title) // -> "Request access to the cake factory"
console.log(taskBox.priority.label) // -> PriorityBox.label -> "HIGH"

taskBox.unfold(); // -> { "id": 19, "title": "Request access to the cake factory", "priority": 3 }

Let's get back to our first attempt, and import data with a different structure.

const taskBox = TaskBox.createFromTodone({
    "taskID": 29,
    "name": "Request access to the cake factory",
    "priority": "high",
});

console.log(taskBox.title) // -> "Request access to the cake factory"
console.log(taskBox.priority.label) // -> PriorityBox.label -> "HIGH"

taskBox.unfoldToTodone(); // -> { "taskID": 29, "name": "Request access to the cake factory", "priority": "high" }

To achive the previous example, it's required to explicitly write down the import and export paths inside of our box. This leeds to a lot of import logic, but that is why we need the box in the first place.

Let's add the following methods to our box.

class TaskBox {
    
    // Create a box from todone data structure
    static createFromTodone(data) {
        if (!data) return null;

        const box = new TodoBox();
        
        box.id = data.taskID;
        box.title = data.name;
        box.priority = PriorityBox.createFromTodone(data.priority);

        return box;
    }

    // Unfold a box to plain data in the structure of todone
    unfoldToTodone() {
        return {
            taskID: this.id,
            name: this.title,
            priority: this.priority?.unfoldToTodone(),
        }
    }

    //...
}

Note: The path of our createFrom* and unfoldTo* calls it's children, they may not change, but they carry the information of the transformation source eg. createFromTodone or unfoldToTodone.

Conclusion

With the explicit declaration of data flows, it's possible to work with "same same but different" data structures. This concept reaches it's potential where you mix and match.

const taskBox = TaskBox.createFromJira({ ... });
taskBox.unfoldToTodoist();

This pattern is a solution to an uncommon problem. If you have any questions or a different way to solve that issue, hit me up on twitter.