PL EN

Strategy Design Pattern

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