Applying design patterns to refactor legacy code
How we used the State and Strategy design patterns to make our Authentication service extensible & reliable
Background
Authentication is a core function of any web-based service and the Funding Societies API backend is no exception. We have always had a separate micro-service written in Java using the Spring Security framework that has been responsible for validating account credentials, issuing tokens and managing their lifecycle. While we handle fetching of account information and the control the logic for authentication by ourselves, we leverage the framework for token issuance & management.
Evolution
The requirements for the authentication micro-service were simple at the start: checking for the existence of the given account with the email address sent and ensuring that the password sent matched the one on record.
However, as the company grew, the requirements got increasingly more complex leading to the micro-service additionally handling some of these concerns:
- Fetching role information
- Decrypting an encrypted password sent from the client
- Sending OTP SMS (among several others)
Whenever a new requirement came in, new services (dependencies) were auto-wired into the primary AuthManager
class.
The authentication logic that used these service objects ended up becoming a deeply nested spaghetti of if-else
statements that looked something like this:
Gradually, it became harder and harder to add logic to support new requirements. How can one know for sure that some existing logic wasn’t inadvertently broken by the addition of a new condition? The deeply nested monolithic code block made it nearly impossible to write unit tests that covered all the scenarios. The criticality of the function that the micro-service was performing forced us to refactor the code for better reliability and future-readiness.
Refactor
By examining the logic, we realised that a couple of software engineering design patterns can be easily applied here: Strategy and State.
Strategy Design Pattern
The micro-service supports various account types (eg. investor, borrower, partner etc.) and different login mechanisms (eg. Basic Auth, Google, Facebook, Partner etc.).
We decided to have a strategy for each application (which is often a combination of an account type and a login mechanism). At the entry point, we pick the right strategy depending on the application. As one can imagine, this can be easily extended in the future to support the requirements of new applications that we build and deploy.
State Design Pattern
We observed that the authentication process can be seen as a series of checks. Each check often performs a single operation and then moves on to the next check. If the check fails, an exception is thrown and the process exits. We realised that this can be easily modelled as a state machine.
To support the movement between states, an AuthContext
class is used to store the relevant information regarding the authentication request. Each of the checks optionally reads or sets values in AuthContext
.
Additionally, we no longer need all the dependent services to be auto-wired globally. Instead, each state can only instantiate and use the dependent services that it needs. This comes in really handy for unit-testing.
Just like the UserExistsState
above, all the individual checks are refactored into AuthState
instances. All states implement the same method next()
which takes a parameter of type AuthContext
.
An authentication strategy can now be seen simply as a composition of one or more of these states wherein the control flow starts at a suitable start state and proceeds until FinalState
is reached executing one check at a time.
This modular design allows us to add new strategies and new states easily and also allows us to move states around within a given strategy if needed.
Benefits
Through this refactoring exercise, we now have a version of the authentication micro-service that is modular, flexible and extensible with high test coverage. Developers used to find it intimidating to add tests earlier for the code block with deeply nested conditional statements. However, with the introduction of the AuthState
abstraction, unit testing has now become very simple as shown below:
This encourages new developers to add tests for any logic that they add making the code behaviour more predictable and the service more reliable for our end-users.