Today's blog is about design patterns, and how I used the strategy design pattern to refactor 11 APIs in a legacy codebase into a single, highly extensible, and maintainable API.
This blog is to show how design patterns are really a useful thing to learn as a software engineer and as a great tool to have in your toolbox.
Design patterns
Design patterns are simply a predefined solution for a given problem. so all you have to do is just implement it to solve your problem.
Problem
The codebase I'm refactoring is about 3 years old and is used in production by real users.
The business component we’ll look at here is the projects component. We have 8 different roles, and each role can take a specific set of actions on a project. It works as a workflow: role X can request edits from role Y, and role Y can return it back or reject the project, etc.
each action for each role is in a separate API endpoint. Some endpoints just use long if/else statements to check the role, all in a single place.
This makes things harder not only for the backend but also for frontend developers, who must keep track of all roles and call a specific endpoint for each specific action. It's just a mess.
Solution
The solution was straightforward. We have a single entity User
that can take a set of actions. The strategy pattern was a natural fit here.
For the implementation, as long as you are solving the problem you are good, you don't have to suffix your classes, interfaces and folder names with strategy. keep it simple.
First, we start with the main interface that all actions should implement:
export interface ChangeProjectStateActionStrategy {
execute(
user: LoggedInUser,
payload: ChangeProjectStatePayloadDto
): Promise<void>;
}
Since we have 8 different roles, each role implements the interface based on the actions it can take.
for example, we have the manager role, he can request edit from role X.
the implementation will be like this
// <RoleName><Action>ActionHandler
export class ManagerRequestEditActionHandler
implements ChangeProjectStateActionStrategy
{
execute(user: LoggedInUser, payload: ChangeProjectStatePayloadDto) {
// business logic here
}
}
this is the folder structure of the change-project-state
command
change-project-state
├── actions
│ ├── accept
│ │ ├── role-x.handler.ts
│ ├── mark-complete
│ │ └── role-y.handler.ts
│ ├── reject
│ │ └── role-x.handler.ts
│ │ └── role-y.handler.ts
│ │ └── role-z.handler.ts
│ ├── request-edit
│ │ ├── role-x.handler.ts
│ │ ├── role-y.handler.ts
│ │ ├── role-x.handler.ts
│ ├── ect...
│ ├── action-handler.registery.ts
Notice the action-handler.registry.ts
file. I created it to register the actions for each role and make it easy to call the right action.
The implementation looks like this:
export class ChangeProjectStateActionHandlerRegistery {
constructor() {} // inject actions
private actionHandlerMap: Record<
ChangeStateActionEnum,
Partial<Record<UserRole, ChangeProjectStateActionStrategy>>
> = {
Accept: {
roleX: this.RoleXAcceptActionHandler,
},
Reject: {
roleY: this.RoleXRejectActionHandler,
},
RequestEdit: {
roleZ: this.RoleXRequestEditActionHandler,
},
// ect...
};
get(
action: ChangeStateActionEnum,
role: UserRole
): ChangeProjectStateActionStrategy {
const handler = this.actionHandlerMap[action][role];
if (!handler) {
throw new NotImplementedException(
`${action} for ${role} is not implemented`
);
}
console.log(`executing ${action} for ${role}`);
return handler;
}
}
Now I can simply pass the action and role and get the appropriate handler:
const handler = this.changeProjectStateActionHandlerRegistery.get(
payload.action,
user.role
);
await handler.execute(user, payload);
This approach not only simplifies the addition of new roles and actions but also improves maintainability by eliminating bloated endpoints and if/else statements.
It also makes frontend development easier, since we remove the need for multiple endpoints per role. A single endpoint now streamlines the entire UI logic.
Since the project is built with Nest.js, dependency injection makes this approach very straightforward.
Hopefully, this blog about the strategy design pattern has shown how it can simplify complex problems and make a developer's job easier. By recognizing the power of design patterns in real-world applications, you'll see how they can dramatically streamline your work and boost your confidence as a developer.