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
- GitHub Repo: Approvals Tutorial