Providing a code example in TypeScript, I will show you how to apply the Strategy pattern. In order to handle the different source of data storage, I refer to data from localStorage or database, respectively.
The Strategy pattern allows you to change the behavior of an object at runtime without modifying the code. It relies on defining a set of algorithms (strategies) that can be swapped during the operation of the application.
The class StorageContext
, selects the appropriate strategy based on the props passed to the constructor. The strategy changes dynamically using the setStrategy
method by retrieving new data accordingly.
export interface StorageStrategy {
getTasks: () => Promise<Response<Task[]>>;
addTask: (task: Task) => Promise<Response<Task>>;
editTask: (
taskId: string,
updatedTask: Partial<Task>,
) => Promise<Response<Task>>;
deleteTask: (taskId: string) => Promise<Response>;
deleteAllTasks: () => Promise<Response>;
}
// StorageContext.ts
export class StorageContext {
private strategy: StorageStrategy;
constructor(strategy: StorageStrategy) {
this.strategy = strategy;
}
setStrategy(strategy: StorageStrategy) {
this.strategy = strategy;
}
async getTasks(): Promise<Response<Task[]>> {
return this.strategy.getTasks();
}
async addTask(task: Task): Promise<Response<Task>> {
return this.strategy.addTask(task);
}
async editTask(
taskId: string,
updatedTask: Partial<Task>,
): Promise<Response<Task>> {
return this.strategy.editTask(taskId, updatedTask);
}
async deleteTask(taskId: string): Promise<Response> {
return this.strategy.deleteTask(taskId);
}
async deleteAllTasks(): Promise<Response> {
return this.strategy.deleteAllTasks();
}
}
With the strategy context prepared, we can create logic for retrieving data from selected sources.
In this example, I don't focus on creating the full logic of the code, but only on showing the use of the pattern.
// LocalStorageStrategy.ts
export class LocalStorageStrategy implements StorageStrategy {
private readonly STORAGE_KEY = ''; // unique key
private saveToLocalStorage(tasks: Task[]) {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(tasks));
}
private clearLocalStorage() {
localStorage.removeItem(this.STORAGE_KEY);
}
async getTasks(): Promise<Response<Task[]>> {
// getTasks function
}
async addTask(task: Task): Promise<Response<Task>> {
// addTask function
}
async editTask(
taskId: string,
updatedTask: Partial<Task>,
): Promise<Response<Task>> {
// editTask function
}
async deleteTask(taskId: string): Promise<Response> {
// deleteTask function
}
async deleteAllTasks(): Promise<Response> {
// deleteAllTasks function
}
}
A similar file should be created for DbStorageStrategy
with functions communicating with backend endpoints.
// DbStorageStrategy.ts
export class DbStorageStrategy implements StorageStrategy {
async getTasks(): Promise<Response<Task[]>> {
// getTasks function
}
async addTask(task: Task): Promise<Response<Task>> {
// addTask function
}
async editTask(
taskId: string,
updatedTask: Partial<Task>,
): Promise<Response<Task>> {
// editTask function
}
async deleteTask(taskId: string): Promise<Response> {
// deleteTask function
}
async deleteAllTasks(): Promise<Response> {
// deleteAllTasks function
}
}
The creation of individual strategies can be wrapped in an additional layer of abstraction, such as the Factory pattern, ensuring that the code conforms to the Open/Closed Principle.
The Strategy pattern hides implementation details of algorithms that implement a specific interface. It makes code easier to maintain and more flexible.
Unit tests for LocalStorageStrategy in Vitest
/// LocalStorageStrategy.test.ts
const getStorageSpy = vi.spyOn(Storage.prototype, 'getItem');
const setStorageSpy = vi.spyOn(Storage.prototype, 'setItem');
describe('LocalStorageStrategy', () => {
let strategy = new LocalStorageStrategy();
afterEach(() => {
localStorage.clear();
getStorageSpy.mockClear();
setStorageSpy.mockClear();
});
it('should fetch tasks from localStorage', async () => {
await strategy.getTasks();
expect(getStorageSpy).toHaveBeenCalledWith(LOCAL_STORAGE_KEY);
});
it('should add a task to localStorage', async () => {
await strategy.addTask(mockedTasks[0]);
expect(setStorageSpy).toHaveBeenCalledWith(
LOCAL_STORAGE_KEY,
JSON.stringify([mockedTasks[0]]),
);
});
it('should edit a task in localStorage', async () => {
await strategy.addTask(mockedTasks[0]);
const updatedTask = { title: 'Updated Task' };
await strategy.editTask(mockedTasks[0]._id, updatedTask);
expect(setStorageSpy).toHaveBeenCalledWith(
LOCAL_STORAGE_KEY,
JSON.stringify([{ ...mockedTasks[0], title: 'Updated Task' }]),
);
});
it('should delete a task from localStorage', async () => {
await strategy.addTask(mockedTasks[0]);
await strategy.deleteTask(mockedTasks[0]._id);
await strategy.getTasks();
expect(setStorageSpy).toHaveBeenCalledWith(LOCAL_STORAGE_KEY, JSON.stringify([]));
});
it('should delete all tasks from localStorage', async () => {
await strategy.addTask(mockedTasks[0]);
await strategy.addTask(mockedTasks[1]);
await strategy.addTask(mockedTasks[2]);
await strategy.deleteAllTasks();
await strategy.getTasks();
expect(setStorageSpy).toHaveBeenCalledWith(LOCAL_STORAGE_KEY, JSON.stringify([]));
});
});
Strategy methods communicating with the backend should be mocked beforehand to test the expected behavior of the function. At the time of publishing the article, I recommend the MSW library for this purpose.
Unit tests for DbStorageStrategy in Vitest
/// DbStorageStrategy.test.ts
describe('DbStorageStrategy', () => {
let strategy: DbStorageStrategy;
beforeEach(() => {
vi.clearAllMocks();
strategy = new DbStorageStrategy();
});
it('should get tasks from API', async () => {
const result = await strategy.getTasks();
expect(result.object).toHaveLength(3);
expect(result.object[0]).toEqual(mockedTasks[0]);
});
it('should add task to API', async () => {
const result = await strategy.addTask(newTask);
expect(result.object).toEqual(newTask);
});
it('should edit task from API', async () => {
const result = await strategy.editTask('1', { title: 'New Title' });
expect(result.object).toEqual({ ...mockedTasks[0], title: 'New Title' });
});
it('should delete task from API', async () => {
const result = await strategy.deleteTask('1');
expect(result.message).toEqual('Task deleted');
});
it('should delete all tasks from API', async () => {
const result = await strategy.deleteAllTasks();
expect(result.message).toEqual('Tasks deleted');
});
});
Advantages:
- Ability to add new strategies
- Individual strategy is responsible for its own implementation of logic
- Dynamically change the behavior of an object as the application runs
Disadvantages:
- Each strategy requires the creation of a separate class
- Creation of additional classes and interfaces
- Complicating the code for smaller applications
Go back to Articles