Introduction

Feature development

Feature development is an important skill for any developer. Striking the balance between a quick and easy solution, with a future-proofed solution is a difficult skill to master. This is especially true when working with different clients that have differing priorities.

The aim of this post

When planning and modelling a situation that requires multiple approvals, it’s essential to consider the possibility of using “Approval Flows.” While simple boolean approvals may work for straightforward processes, Approval Flows provide a more structured approach for managing complex approval processes with multiple steps, approvers, and specific workflows. By implementing an Approval Flow system, we can ensure that the approval process is transparent, efficient, and completed promptly. This approach allows us to track progress in real-time, address bottlenecks, and prevent chaos and inefficiencies in the approval process, ultimately leading to better outcomes.

Requirements inspection

Understanding the business needs of the feature

Problem statement

Before merging changes to the codebase, a Pull Request is created and feedback is required from other stakeholders (code contributors). This feedback can take the shape of an Approval, a Change Request or a General Comment. Multiple Approvals, Change Requests and Comments (and any combination of them) can be added to a Pull Request and the status of the pull request depends on the feedback that has been left.

Statuses

  • “Awaiting Review” - the Pull Request has either no feedback or only has general Comments
  • “Change Requested” - the Pull Request has a Change Request attached. The Pull Request may have other Comments but no Approvals
  • “Approved” - the Pull Request has at least 1 Approval

The above problem statement contains some giveaways that a simplistic approach to the Approval process wouldn’t be ideal - “Multiple” is my trigger word in this statement.

If the requirements were simpler - “the Pull Request is either ‘approved’ or not” - then we could go with an approach where we would add an approved: true attribute to the Pull Request model.

erDiagram

PullRequest {
  boolean approved
}

Going for the simplest option here, we would ship the feature in minutes. Inevitably, in the real world, an additional requirement of “we need to know when and who approved the Pull Request” would see us implement something like

erDiagram
PullRequest {
	datetime approved_at
	uuid approver "FK users.id"
}

Examining the data and relationships at play

Looking further at the requirements we have some stand-out nouns Pull Request, Approval, Change Request and Comment.

erDiagram

PullRequest
Approval
ChangeRequest
Comment

We also have a slightly hidden noun when we talk about the who or the action taker. For the sake of simplicity, we’ll assume that there is a User noun hiding under the surface.

erDiagram

PullRequest
Approval
ChangeRequest
Comment
User

It should be noted that the PullRequest could be switched out for any other model that your domain needs to be approved, the same could be said for the User.

Relationships

From the problem statement, we see that a Pull Request can have many Approvals, Change Request and Comments. We can also assume that Approvals, Change Request and Comments all have a User attached to them. By updating our relationship diagram we would have

erDiagram

PullRequest
Approval
ChangeRequest
Comment
User

Approval }o--|| PullRequest : ""
ChangeRequest }o--|| PullRequest : ""
Comment }o--|| PullRequest : ""

Approval }o--|| User : ""
ChangeRequest }o--|| User : ""
Comment }o--|| User : ""

Pinpointing the feature’s entities and attributes

Digging into the feature, feedback is at the heart of our feature. We want our modelling to reflect that and allow a User to record the feedback no matter the kind of feedback. For this, we can add a body free text attribute for each feedback type.

erDiagram

PullRequest {
  text body
}
Approval {
  text body
}
ChangeRequest {
  text body
}
Comment {
  text body
}
User

Approval }o--|| PullRequest : ""
ChangeRequest }o--|| PullRequest : ""
Comment }o--|| PullRequest : ""

Approval }o--|| User : ""
ChangeRequest }o--|| User : ""
Comment }o--|| User : ""

Schema design

As Ruby on Rails developers we can make a few assumptions about some of the attributes our models will have based on their relationships and other Rails defaults. Expanding the diagram above with all the attributes would look like

erDiagram

PullRequest {
  uuid id
  text body
  datetime created_at
  datetime updated_at
}
Approval {
  uuid id
  text body
  uuid pull_request_id
  uuid user_id
  datetime created_at
  datetime updated_at
}
ChangeRequest {
  uuid id
  text body
  uuid pull_request_id
  uuid user_id
  datetime created_at
  datetime updated_at
}
Comment {
  uuid id
  text body
  uuid pull_request_id
  uuid user_id
  datetime created_at
  datetime updated_at
}
User {
  uuid id
  string name
  datetime created_at
  datetime updated_at
}

Approval }o--|| PullRequest : ""
ChangeRequest }o--|| PullRequest : ""
Comment }o--|| PullRequest : ""

Approval }o--|| User : ""
ChangeRequest }o--|| User : ""
Comment }o--|| User : ""

For the rest of this post, we’ll remove the timestamps and relation_id attributes.

The design above highlights a lot of duplicated attributes. These are usually the first candidates for refactoring. The Approval, ChangeRequest and Comment all look exactly the same - there are no additional attributes in any one of the models. These are good candidates for single-table inheritance (STI).

erDiagram

Comment {
  uuid id
  text body
  uuid pull_request_id
  uuid user_id
  string type "reserved word for STI in Rails"
}

User {
  uuid id
  string name
  datetime created_at
  datetime updated_at
}

Comment }o--|| PullRequest : ""
Comment }o--|| User : ""

So where do the Approval and ChangeRequest models go? Well, they can now be Ruby models rather than standalone database tables, which reduces the footprint of our database while keeping our Ruby code clean.

Modeling madness

Sanity checking code uses

We have already made good headway in working out and planning what our models will look like. The approach that I like to take at this point is to explore what some of our Ruby code could look like. I take this approach as an extension of an approach that I’ve watched Ben Orenstien use

Write the code you wish you had

There are a couple of areas that we would like to explore, mainly the code around the status. Some examples of what we might need are

# Get the status of a Pull Request
pull_request.status
#=> "awaiting approval" | "change requested" | "approved"

# Get all Pull Requests that are awaiting approval
PullRequest.awaiting_approval
# Get all Pull Requests that have change requests
PullRequest.changes_requested
# Get all Pull Requests that have been approved
PullRequest.approved

We will also want to display a list (timeline) of all the feedback left on a Pull Request and that could look like

pull_request.feedback
#=> [<Comment>, <ChangeRequest>, <Approval>]

The reasoning behind exploring some code at this point is to make sure there are no obvious things that could trip us up. It also helps identify any pain points with the modelling or any other models/attributes that we may have missed.

Constructing models, associations and validations

We have a lot of information about what our models and relationships should look like and our code exploration hasn’t thrown anything unusual - it has confirmed that we’re along the right lines for modelling.

We’ll now turn our notes into Rails models

class Comment < ApplicationRecord
	belongs_to :pull_request
	belongs_to :user
	
    validates_presence_of :body
end

class ChangeRequest < Comment
end

class Approval < Comment
end

class PullRequest < ApplicationRecord
	has_many :feedbacks, class_name: "Comment" #returns all comment types
	has_many :comments, -> {where(type: nil)} #returns only type nil (regular Comment)
	has_many :change_requests
	has_many :approvals
end

Crafting custom methods for models

For the Pull Request model, we need a method to determine the status. We can determine the status of the Pull Request by checking which kinds of Comments have been attached.

class PullRequest < ApplicationRecord
	has_many :feedbacks, class_name: "Comment" #returns all comment types
	has_many :comments, -> {where(type: nil)} #returns only type nil (regular Comment)
	has_many :change_requests
	has_many :approvals

	def status
		if approvals.any?  
			:approved  
		elsif change_requests.any?  
			:change  
		else  
			:awaiting_approval  
		end
	end
end

Conclusion

The modelling journey

We have taken the seemingly simple requirements, explored a basic solution that didn’t meet the requirements, expanded the problem out to an overly verbose solution and refactored our thoughts back to an elegant solution that hits all the requirements.

The overall design lends itself to expansion and modifications but ultimately supports all the functionality for now and a little way into the future.

Lessons for readers

Planning and modelling is a subject that doesn’t get much attention, especially in these “agile” days where planning seems to be skipped.

There is a balance to be found in not planning too far ahead but also being realistic in the assumption that there most likely will be more requirements for this feature.

Single Table Inheritance is a pattern that may not be popular either and I’m sure there are other ways that this can be implemented, however as a starting point STI gets us a long way.

References

Further reading references