Visual Automations (VA) sit at the core of the user experience inside of ConvertKit. As an email marketing tool, our job is to help our creators (customers) send the right messages to the right audience at the right time, all while making the communication feel personal and tailored.

Visual automations is the largest feature we’ve released since ConvertKit was founded and it will continue to fuel our business (and our creators) for years to come. Building something so big is one heck of a task, and as an engineer I want to share what exactly goes into a project like this.

For context, here’s what a visual automation looks like in ConvertKit:

As part of the team that worked to build VA, I’ll use this post to explain how our application gets the content from our server and creates a data structure that is useful for us to render a Visual Automation. In other words, I’ll cover everything from when the data is received on the browser until we start rendering the graph on the screen.

Data coming in

One thing we settled on from the get go is that all the communication would be done through JSON. After a few minutes discussion, we thought that the JSON would look to something like this:

A JSON structure for Visual Automation

The final JSON was a little bit more complex than that, but that’s essentially what I was going to work with. I like to build features with small goals in sight. Rendering a working VA is the end goal, but if you try to go from JSON to a rendered graph on your first step, it’s very easy to get overwhelmed.

One approach is getting validation early and often. Validations can come in different forms and there are no silver bullets. I knew from previous discussion that VA could be super complex. In Ruby, we can manage this complexity with POROs (plain old ruby objects) and I thought that I could do the same with JavaScript. In this case I would get the JSON, run it through a parser and at the end of it, I would have something that I could use to help me render the data on the screen.

The steps as I saw them were as follows:

A Linker that links nodes together through edges

that links together through A Builder that would take the linker and build a useful data structure

that would take the linker and build a useful data structure A Walker that would make it possible to render the graph in some fashion

These were the initial goals. Create the linker first, then use the linker to create the Builder, then use the builder to create the Walker. I decided on this approach from the beginning, not because it was the best solution, but because it allowed me to compartmentalize the complexity.

I knew how to build the linker, but I had no idea how to build the other two components. I needed a small win.

Step 1: Creating the Linker

The linker seemed to be straight forward. It would return an array of N objects where the objects would be the starting edges of a graph. So, if a graph has 3 entry points, the array will have a count of 3.

In the initial JSON, edges only have ids of the nodes. The linker would replace those ids with Node and reuse the same object when a node has the same id.

It looks something like this:

Disclaimer: It’s just pseudo-code, be gentle :)

Once the process method is called, a new getter is created with a frozen array. No, frozen arrays aren’t bulletproof, but it makes sense to restrict things to force decoupling as much as possible. All in all, the version we ended up with is pretty close to what you see above. Goal achieved!

With this first step done, I realized that things would not be straightforward after all. Multiple edges could start from the same node and also merge to the same node. I also started to think about the larger concept of the graph, the core concepts that we need when building Visual Automations on the screen.

The Builder class started to make more sense because it had a purpose now. Its job would be to create a structure that would make it possible to find merging/branching points in the graph and help the Walker figure out which nodes to render.

Step 2: Creating the Builder and figuring out the graph as a whole

The Builder is the bridge between the linked data and the upcoming Walker. In other words, the Builder is the glue that holds everything together. The goal is to provide information about the Graph in a way that’s actionable down the road for presenting the VA to the user.

Visual Automations are rendered row by row. When one side of the graph is finished before the other, there is space allocated to let the other side finish. While it’s easy for humans to visualize this, it’s harder for a computer to figure that out. So, at some point I’d need some logic to figure out when to pause a side and when not to.

I hosted a heated debate in my head as to where this should occur:

Should it be natively built in the graph through the Builder ?

? Should it be accounted for only when rendering?

It turns out that this is mostly subjective. It depends on how you approach the problem. I chose to stick with my strategy of creating small wins. If I was going to incorporate spacing into the graph natively, the Builder class would need to deal with a lot more logic and states. For that reason, I chose to figure out what kind of information I would need in order to add proper spacing.

Perhaps organizing the data structure would make it easier to add spacing further down?

As I was thinking about that question, I had the idea of naming the substructure of the graph with different terms so I could think more clearly about what I wanted to do.

I drew a graph on a piece of paper:

This is the first Visual Automation!

Looking at that drawing, I realized that I could split the graph in 3 parts:

Individual nodes

Branches that group together linear nodes

Lines, which are unique paths from the top of the graph to the bottom

The way this works is that branches are created from nodes and lines are created from branches. No branch can have the same set of nodes, no lines can have the same set of branches. That means that no lines can have exactly the same nodes set.

To make this easier, here’s another image of a simpler graph.

This graph has 7 nodes, 5 branches and 4 lines. Here’s the breakdown of the branches on this graph:

1–3

2–3

3–4–5

5–6

5–7

And lines are the ordered combinations of those branches, where the branch 3–4–5 is present in all lines.

With that information, I could write the Builder class with a goal in mind: to move from a linked list of nodes to an array of Line objects. As it turns out, building that process was complicated, even after trimming down the feature set for that particular class. It involved edge traversal, slicing branches as branching points were found, creating new branches when merging points appeared, and more.

As I was writing this logic, a lot of finding/slicing/adding/removing operations surfaced. Even though I didn’t create the Branch concept with these in mind, it was a perfect home for them.

While my focus was on building the Builder class, this Branch class turned out to take most of my time. Whenever I needed to make an operation on the graph, the Branch object had all the tools to do the job.

The Builder class was going through nodes, looking at the current state of the graph and finding out if a node needed to be added to a branch, removed from an existing branch, etc.

Once that was determined, the mission of doing the actual work was delegated to the Branch object. Because I was using the same object, any operation in a branch in one Line would automatically happen on all other lines.

By the time the Builder class was done and it was returning the Line array, I had all the things I needed to start building the Walker . Soon it would be possible to render a VA on the screen!

Step 3: Putting all the things together with the Walker

I mentioned earlier that our Visual Automation tool has unique features. One of those features is that we render the graph row by row. Sometimes that means it needs some space to let another Line finish. The Walker is where the magic happens.

The idea is to start at index = 0 and walk through the graph row by row until the end of the graph. On each row, the walker is able to return an array of nodes as well as spacers (we call them placeholders) in the right order. This allows the rendering class to take those objects and render them properly on the screen.

At this point, the original JSON went through a bit of transformation and we added a lot of information to the data through objects, compositions, etc. The idea is to leverage all of this to figure out when to pause lines.

Going back to the Line object, one of the rules is that there must be a unique path from the start of a graph to its end. That means that if there are two Line elements with the same node that aren’t at the same index in its internal array, the shorter Line needs to be paused until it reaches the same index so it can merge at the same row.

And that’s how the VA decides when to pause lines. It ensures all the Line elements that include a certain node are in the same row before rendering it. Those that have more nodes keep rendering nodes, others just render placeholders.

This is how we figure it out in the code:

Get all the positions for a given node, take the maximum number, if that number is bigger than the current index, this Line needs to be paused. When it reaches equality, the walker returns the paused Node instead.

Now that all the blocks are working as intended, it is now possible to render a graph using the Walker through a do {} while; .

Here’s a snippet of our production code that renders the graph on the screen:

Voila, a Visual Automation

These small steps are all (hehe) that’s required in the end to render something like this:

Following the strategy of splitting up big work into smaller chunks and making sure to get small wins along the way helped me tremendously. By taking this approach, I was able to find places to decouple code between classes and use composition to get to a final state.

That’s a peek behind the curtain at how we approached building Visual Automations, our largest new feature release in our history (to date). Have questions? Comment in-line and I’ll drop back in to answer them.