02 - Strategy Pattern

Abbiamo un semplice programma che gestisce il log di attività e calcola il loro valore. La cosa prende piede e ci arriva la richiesta di rendere più configurabile il comportamento:

  • poter arrotondare la durata alla mezzora
  • rendere configurabile il valore orario
  • avere la possibilità di definire un valore minimo di durata sotto il quale il valore fatturabile è 0

Problema - Poter arrotondare la durata alla mezzora

L'utente può decidere se tenere il calcolo delle ore preciso o se arrotondarlo alla mezzora.
Per il momento non abbiamo nemmeno la logica per gestire gli utenti, ma con quello che abbiamo visto le volte scorse non dovrebbe essere un problema, possiamo definire un datasource finto per le impostazioni e poi sostituirlo con quello reale quando sarà il momento.

La configurazione è nel formato:

interface DurationSettings {
	strategy: 'exact' | 'rounded';
}
  • creiamo il rounded-duration service e testiamolo individualmente

rounded-duration.service.ts

import { TimeEntryDurationService } from "./duration.service";

export class RoundedTimeEntryDurationService extends TimeEntryDurationService {
  constructor(protected roundValue:number = 30) { 
    super();
  }

  calcDuration(millis: number): number {
    const minutes = millis / (1000 * 60);
    const rounded = Math.round(minutes / this.roundValue) * this.roundValue;
    return rounded / 60;
  }
}
  • Creiamo un duration settings datasource e la sua finta implementazione statica
    duration-settings.entity.ts
export interface DurationSettings {
  strategy: 'exact' | 'rounded';
}

duration.settings.ds.service.ts

import { DurationSettings } from "./duration-settings.entity";
import { Injectable } from "@nestjs/common";

@Injectable()
export abstract class DurationSettingsDataSource {
  constructor() {
    console.log('DurationSettingsDataSource created');
  }
  abstract getDurationSettings(): Promise<DurationSettings>;
}

duration-settings.ds.static.service.ts

import { Inject } from "@nestjs/common";
import { DurationSettings } from "./duration-settings.entity";
import { DurationSettingsDataSource } from "./duration.settings.ds.service";

export const STATIC_DURATION_STRATEGY = 'STATIC_DURATION_STRATEGY';

export class DurationServiceStaticDataSource extends DurationSettingsDataSource {
  constructor(@Inject(STATIC_DURATION_STRATEGY) protected readonly strategy: DurationSettings['strategy']) {
    super();
  }
  async getDurationSettings(): Promise<DurationSettings> {
    return {
      strategy: this.strategy
    };
  }

}
  • adesso ci serve un modo per andare a selezionare quale duration service utilizzare a seconda dei settings

time-entry.controller.ts

import { TimeEntryDataSource } from './datasource/time-entry.ds.service';
import {
  Body,
  Controller,
  Get,
  HttpException,
  HttpStatus,
  Param,
  Post,
  UsePipes,
  ValidationPipe,
} from '@nestjs/common';
import { CalculatedTimeEntry } from './entities/time-entry.entity';
import { CreateTimeEntryDTO } from './entities/time-entry.dto';
import { TimeEntryResultFactory } from './entities/time-entry-result.factory';
import { TimeEntryDurationService } from './duration/duration.service';
import { TimeEntryAmountService } from './amount/amount.service';
import { DurationSettingsDataSource } from './duration-settings/duration.settings.ds.service';
import { ExactTimeEntryDurationService } from './duration/exact-duration.service';
import { RoundedTimeEntryDurationService } from './duration/rounded-duration.service';

@Controller('time-entries')
export class TimeEntryController {
  constructor(
    protected readonly dataSorce: TimeEntryDataSource,
    protected readonly durationSrv: TimeEntryDurationService,
    protected readonly amountSrv: TimeEntryAmountService,
    protected readonly resultFactoryProvider: TimeEntryResultFactory,
    protected readonly durationSettingsSrv: DurationSettingsDataSource) {}

  @Get()
  async list(): Promise<CalculatedTimeEntry[]> {
    const list = await this.dataSorce.list();
    const durationSettings = await this.durationSettingsSrv.getDurationSettings();
    let durationSrv: TimeEntryDurationService;

    switch (durationSettings.strategy) {
      case 'rounded':
        durationSrv = new RoundedTimeEntryDurationService(30);
      case 'exact':
        durationSrv = new ExactTimeEntryDurationService();
        break;
    }

    const resultFactory = this.resultFactoryProvider.getFactory(durationSrv, this.amountSrv);
    return list.map((e) => {
      return resultFactory(e);
    });
  }

  @Get(':id')
  async detail(@Param('id') id: string) {
    const record = await this.dataSorce.get(id);
    if (!record) {
      throw new HttpException('Not found', HttpStatus.NOT_FOUND);
    }
    const resultFactory = this.resultFactoryProvider.getFactory(this.durationSrv, this.amountSrv);
    return resultFactory(record);
  }

  @Post()
  @UsePipes(new ValidationPipe({transform: true}))
  async create(@Body() createTimeEntryDTO: CreateTimeEntryDTO) {
    const record = await this.dataSorce.add(createTimeEntryDTO);
    const resultFactory = this.resultFactoryProvider.getFactory(this.durationSrv, this.amountSrv);
    return resultFactory(record);
  }
}

Fermiamoci direttamente qui perché abbiamo abbastanza esperienza per capire che questo approccio è problematico.

Andiamo ad esportare la logica in un'altra classe:

duration-strategy-selector.service.ts

import { Injectable } from "@nestjs/common";
import { TimeEntryDurationService } from "./duration.service";

@Injectable()
export class DurationStrategySelectorService {
  protected strategies: {[key: string]: TimeEntryDurationService} = {};

  addStrategy(identifier: string, strategy: TimeEntryDurationService) {
    this.strategies[identifier] = strategy;
  }

  getStrategy(identifier: string): TimeEntryDurationService {
    if (!this.strategies[identifier]) {
      throw new Error(`Duration strategy ${identifier} not found`);
    }
    return this.strategies[identifier];
  }
}

Come andiamo a popolare la lista delle strategie disponibili?
Per il momento lo andiamo a fare tramite la definizione dei provider, avremmo potuto fare un servizio che già include la definizione della lista, ma delegando ad altri il compito lo rendiamo più versatile per il futuro (ad esempio caricare liste diverse a seconda del piano a pagamento di un utente) e più semplice da testare.

time-entry.module.ts

	...
	ExactTimeEntryDurationService,
    RoundedTimeEntryDurationService,
    {
      provide: DurationStrategySelectorService, 
      useFactory: (exact, rounded) => {
        const srv = new DurationStrategySelectorService();
        srv.addStrategy('exact', exact);
        srv.addStrategy('rounded', rounded);
        return srv;
      },
      inject: [ExactTimeEntryDurationService, RoundedTimeEntryDurationService]
    }

il nostro controller adesso diventa:
time-entry.controller.ts

import { TimeEntryDataSource } from './datasource/time-entry.ds.service';
import {
  Body,
  Controller,
  Get,
  HttpException,
  HttpStatus,
  Param,
  Post,
  UsePipes,
  ValidationPipe,
} from '@nestjs/common';
import { CalculatedTimeEntry } from './entities/time-entry.entity';
import { CreateTimeEntryDTO } from './entities/time-entry.dto';
import { TimeEntryResultFactory } from './entities/time-entry-result.factory';
import { TimeEntryAmountService } from './amount/amount.service';
import { DurationSettingsDataSource } from './duration-settings/duration.settings.ds.service';
import { DurationStrategySelectorService } from './duration/duration-strategy-selector.service';

@Controller('time-entries')
export class TimeEntryController {
  constructor(
    protected readonly dataSorce: TimeEntryDataSource,
    protected readonly amountSrv: TimeEntryAmountService,
    protected readonly resultFactoryProvider: TimeEntryResultFactory,
    protected readonly durationSettingsSrv: DurationSettingsDataSource,
    protected readonly durationStrategySelector: DurationStrategySelectorService) {}

  @Get()
  async list(): Promise<CalculatedTimeEntry[]> {
    const list = await this.dataSorce.list();
    
    const durationSettings = await this.durationSettingsSrv.getDurationSettings();
    const durationSrv = this.durationStrategySelector.getStrategy(durationSettings.strategy);
    
    const resultFactory = this.resultFactoryProvider.getFactory(durationSrv, this.amountSrv);
    return list.map((e) => {
      return resultFactory(e);
    });
  }

  @Get(':id')
  async detail(@Param('id') id: string) {
    const record = await this.dataSorce.get(id);
    if (!record) {
      throw new HttpException('Not found', HttpStatus.NOT_FOUND);
    }

    const durationSettings = await this.durationSettingsSrv.getDurationSettings();
    const durationSrv = this.durationStrategySelector.getStrategy(durationSettings.strategy);

    const resultFactory = this.resultFactoryProvider.getFactory(durationSrv, this.amountSrv);
    return resultFactory(record);
  }

  @Post()
  @UsePipes(new ValidationPipe({transform: true}))
  async create(@Body() createTimeEntryDTO: CreateTimeEntryDTO) {
    const record = await this.dataSorce.add(createTimeEntryDTO);

    const durationSettings = await this.durationSettingsSrv.getDurationSettings();
    const durationSrv = this.durationStrategySelector.getStrategy(durationSettings.strategy);

    const resultFactory = this.resultFactoryProvider.getFactory(durationSrv, this.amountSrv);
    return resultFactory(record);
  }
}

e aggiungiamo dei test al controller, ma lo facciamo in modo un po' diverso, cercando di fare un po' di ordine. Facciamo una sezione apposta per verificare solo che i metodi per tornare i settings e la strategy vengano chiamati correttamente.

time-entry.controller.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { TimeEntryController } from './time-entry.controller';
import { TimeEntryDataSource } from './datasource/time-entry.ds.service';
import { TimeEntryMockDataSource } from './mocks/time-entry.ds.mock.service';
import { Types } from 'mongoose';
import { TimeEntry } from './entities/time-entry.schema';
import { FixedAmountService } from './amount/fixed-amount.service';
import { TimeEntryAmountService } from './amount/amount.service';
import { TimeEntryResultFactory } from './entities/time-entry-result.factory';
import { DurationServiceStaticDataSource, STATIC_DURATION_STRATEGY } from './duration-settings/duration-settings.ds.static.service';
import { DurationSettingsDataSource } from './duration-settings/duration.settings.ds.service';
import { DurationStrategySelectorService } from './duration/duration-strategy-selector.service';
import { ExactTimeEntryDurationService } from './duration/exact-duration.service';

describe('TimeEntryController', () => {
  let controller: TimeEntryController;
  let dataSource: TimeEntryMockDataSource;
  let spyFactory: jest.SpyInstance;
  let spyResult: jest.Mock;
  let spyDurationSettings: jest.SpyInstance;
  let spyStrategyProvider: jest.SpyInstance;
  

  beforeEach(async () => {
    dataSource = new TimeEntryMockDataSource();

    const app: TestingModule = await Test.createTestingModule({
      controllers: [TimeEntryController],
      providers: [{
        provide: TimeEntryDataSource,
        useValue: dataSource
      },
      {provide: TimeEntryAmountService, useClass: FixedAmountService},
      TimeEntryResultFactory,
      {provide: STATIC_DURATION_STRATEGY, useValue: 'exact'},
      {provide: DurationSettingsDataSource, useClass: DurationServiceStaticDataSource},
      DurationStrategySelectorService
    ],
    }).compile();

    controller = app.get<TimeEntryController>(TimeEntryController);
    
    const durationSettings = app.get<DurationSettingsDataSource>(DurationSettingsDataSource);
    spyDurationSettings = jest.spyOn(durationSettings, 'getDurationSettings');

    const durationStrategyProvider = app.get<DurationStrategySelectorService>(DurationStrategySelectorService);
    durationStrategyProvider.addStrategy('exact', new ExactTimeEntryDurationService());
    spyStrategyProvider = jest.spyOn(durationStrategyProvider, 'getStrategy');

    const resultFactory = app.get<TimeEntryResultFactory>(TimeEntryResultFactory);
    spyResult = jest.fn().mockResolvedValue({});
    spyFactory = jest.spyOn(resultFactory, 'getFactory');
    spyFactory.mockReturnValue(spyResult);
  });

  describe('duration strategy', () => {
    const records: TimeEntry[] = [
      {
        id: new Types.ObjectId(),
        description: 'Test1',
        start: new Date(),
        end: new Date(),
        billable: true
      },
      {
        id: new Types.ObjectId(),
        description: 'Test2',
        start: new Date(),
        end: new Date(),
        billable: true
      }
    ];
    beforeEach(() => {
      dataSource.setRecords(records);
    })

    it('LIST: should call the settings provider', async () => {
      try {
        await controller.list();
      } catch (_) {}
      finally {
        expect(spyDurationSettings).toHaveBeenCalled();
      }
    })
    it('DETAIL: should call the settings provider', async () => {
      try {
        await controller.detail(records[0].id.toString());
      } catch (_) {}
      finally {
        expect(spyDurationSettings).toHaveBeenCalled();
      }
    })
    it('CREATE: should call the settings provider', async () => {
      try {
        const record = {
          description: 'Test1',
          start: new Date('2024-01-10T10:00:00.000Z'),
          end: new Date('2024-01-10T11:00:00.000Z'),
          billable: true
        }
        await controller.create(record);
      } catch (_) {}
      finally {
        expect(spyDurationSettings).toHaveBeenCalled();
      }
    })

    it('LIST: should request the right duration strategy', async () => {
      spyDurationSettings.mockResolvedValue({strategy: 'test'});
      try {
        await controller.list();
      } catch(_) {}
      finally {
        expect(spyStrategyProvider).toHaveBeenCalledWith('test');
      }
    })

    it('DETAIL: should request the right duration strategy', async () => {
      spyDurationSettings.mockResolvedValue({strategy: 'test'});
      try {
        await controller.detail(records[0].id.toString());
      } catch(_) {}
      finally {
        expect(spyStrategyProvider).toHaveBeenCalledWith('test');
      }
    })

    it('CREATE: should request the right duration strategy', async () => {
      spyDurationSettings.mockResolvedValue({strategy: 'test'});
      try {
        const record = {
          description: 'Test1',
          start: new Date('2024-01-10T10:00:00.000Z'),
          end: new Date('2024-01-10T11:00:00.000Z'),
          billable: true
        }
        await controller.create(record);
      } catch(_) {}
      finally {
        expect(spyStrategyProvider).toHaveBeenCalledWith('test');
      }
    })
  })

  describe('list',  () => {
    it('should return a list of elements"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date(),
          end: new Date(),
          billable: true
        },
        {
          id: new Types.ObjectId(),
          description: 'Test2',
          start: new Date(),
          end: new Date(),
          billable: true
        }
      ];
      dataSource.setRecords(records);
      return controller.list().then(result => {
        expect(spyFactory).toHaveBeenCalled();
        for(let i = 0; i < records.length; i++) {
          expect(spyResult).toHaveBeenNthCalledWith(i+1, records[i]);
        }
        expect(result.length).toBe(records.length);
      })
    });
  });

  describe('detail', () => {
    it('should return a single record with amount"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date(),
          end: new Date(),
          billable: true
        },
        {
          id: new Types.ObjectId(),
          description: 'Test2',
          start: new Date(),
          end: new Date(),
          billable: true
        }
      ];
      dataSource.setRecords(records);
      return controller.detail(records[1].id.toString()).then(result => {
        expect(spyFactory).toHaveBeenCalled();
        expect(spyResult).toHaveBeenCalledWith(records[1]);
        expect(result).toStrictEqual({});
      })
    });

    it('should throw an exception if not found"', async () => {
      const records: TimeEntry[] = [
        {
          id: new Types.ObjectId(),
          description: 'Test1',
          start: new Date('2024-01-10T10:00:00.000Z'),
          end: new Date('2024-01-10T11:00:00.000Z'),
          billable: false
        }
      ];
      dataSource.setRecords(records);
      return expect(controller.detail('test')).rejects.toThrow('Not found');
    });

  });

  describe('create', () => {
    it('should add a new record', async () => {
      const record = {
        description: 'Test1',
        start: new Date('2024-01-10T10:00:00.000Z'),
        end: new Date('2024-01-10T11:00:00.000Z'),
        billable: true
      }
      return controller.create(record).then(result =>{
        expect(spyFactory).toHaveBeenCalled();
        expect(spyResult).toHaveBeenCalled();
        expect(result).toStrictEqual({});
      })
    });
  })
});

Soluzione - Strategy Pattern

Andiamo a vedere il primo pattern vero e proprio, che abbiamo appena usato nel nostro codice anche se non lo conoscevamo.

Definizione

Strategy is a behavioral design pattern that lets you define a family of algorithms, put each of them into a separate class, and make their objects interchangeable.

Nel caso in cui ci siano diverse logiche alternative tra cui scegliere Strategy Pattern suggerisce di separarle in classi (o file) diverse chiamate =strategies=.

La classe che contiene la logica con cui viene scelta una delle strategie viene chiamata =context=, si occupa solo di scegliere la strategia e rimanda a lei l'esecuzione della logica specifica per risolvere il problema.

Il context è indipendente dalle strategy che gestisce, queste implementano un'interfaccia comune che espone il metodo necessario per eseguire la logica.

Il client usa il context per indicare quale strategy vuole utilizzare e il context esegue la logica scegliendo tra le strategie.

Nel nostro esempio ci siamo un po' spostati da queste regole, ma il core dello strategy pattern è ancora li, i modi per calcolare la durata sono le nostre strategie, lo strategy selector è il nostro context, nella dependency injection del modulo siamo il client che definisce quali strategie ci sono a disposizione e nel controller siamo un altro client che va a richiedere la strategia più adatta.
Quello che facciamo di diverso rispetto alla definizione standard è andare a tornare l'istanza della strategia invece di far eseguire il codice direttamente al context. Facciamo questo per mantenere la possibilità di utilizzare direttamente un DurationService nelle altre parti del codice, ma non c'è problema, abbiamo comunque preso tra una serie di classi e abbiamo scelto quella con la logica più adatta, e il resto del programma non sa e non ha interesse a sapere la scelta che abbiamo fatto.
Ricordatevi che i Design Pattern descrivono la struttura logica del codice e non l'implementazione specifica.

Definizione di un design pattern

Andiamo a vedere la descrizione dello Strategy Pattern su refactoring.guru e cominciamo a capire meglio come vengono descritti e la terminologia usata.

Struttura di un pattern

La definizione di un pattern consiste in:

Intent

descrive brevemente sia il problema che la soluzione

es: Strategy is a behavioral design pattern that lets you define a family of algorithms, put each of them into a separate class, and make their objects interchangeable.

Motivation

descrive in maggiore dettaglio problema e soluzione, all'occorrenza fornendo esempi pratici

es: sono le sezioni Problem e Solution del sito

Structure

descrive la struttura delle classi e come sono relazionate tra di loro

es: la classe context contiene una referenza delle strategie, l'interfaccia strategy viene implementata dalle soluzioni concrete, ecc

Code example

fornisce uno o più esempi in linguaggi comuni o pseudo-codice

es: nel sito in fondo ci sono gli esempi e c'è un'altra sezione in cui è possibile vederne in diversi linguaggi

Classificazione

I pattern si dividono in 3 categorie a seconda dello scopo:

Creational

forniscono meccanismi diversi per creare oggetti aumentando la flessibilità e riusabilità

factory method, abstract factory, builder, prototype, singleton

Structural

descrivono come relazionare tra di loro classi e funzionalità aumentando la flessibilità o l'efficienza

adapter, bridge, composite, decorator, facade, flyweight, proxy

Behavioral

si occupano di migliorare la comunicazione e delegare le responsabilità tra oggetti

chain of responsibility, command, iterator, mediator, memento, observer, state, strategy, template method, visitor

Da notare che una soluzione può usare anche più pattern, dentro la logica di una factory ad esempio potrebbe essere utilizzato un iterator o una strategy.

Relazioni tra oggetti

Spesso nelle definizioni vedremo frasi del tipo "ridurre la dipendenza tra le classi", o nei diagrammi che descrivono la struttura vedremo frecce piene, frecce vuote, rombi, ecc.
Questi termini e queste rappresentazioni servono a descrivere la relazione tra le classi.

Dependency

Dependency is the most basic and the weakest type of relations between classes. There is a dependency between two classes if some changes to the definition of one class might result in modifications to another class. Dependency typically occurs when you use concrete class names in your code. For example, when specifying types in method signatures, when instantiating objects via constructor calls, etc. You can make a dependency weaker if you make your code dependent on interfaces or abstract classes instead of concrete classes.

Il problema non è tanto che una classe dipenda da un altra, ma il fatto che la relazione dovrebbe dipendere esclusivamente da una interfaccia condivisa e limitata. In questo modo le modifiche alla dipendenza hanno meno possibilità di impattare l’implementazione della classe che la utilizza.

Soluzione: utilizzare abstract class o interfacce, in modo da poter cambiare l’implementazione senza dover modificare il codice della classe utilizzatrice.

Association

Association is a relationship in which one object uses or inter- acts with another.

E’ molto simile a una dependency, la differenza sta nel fatto che una dipendenza non è necessariamente un collegamento perenne tra i due oggetti (ad esempio una dipendenza potrebbe essere una classe che viene usata come argomento di un metodo), in una associazione invece il collegamento è perenne (una proprietà della classe ad esempio).

Possibili problemi:
se si usano ad esempio dei metodi del campo associato c’è il rischio che il codice si rompa se viene cambiata l’implementazione della classe associata.

Possibili soluzioni:

  • usare interfacce o abstract class nelle associazioni e mantenere fissa la loro definizione, in questo modo si può andare a cambiare l’implementazione senza problemi

Aggregation

Aggregation is a specialized type of association that represents “one-to-many”, “many-to-many” or “whole-part” relations between multiple objects.

Di solito in una aggregazione c’è almeno un componente che funge da aggregatore per degli altri oggetti.

Composition

Composition is a specific kind of aggregation, where one object is composed of one or more instances of the other. The distinction between this relation and others is that the component can only exist as a part of the container.

La differenza con Aggregation è che sia il contenitore che il componente non possono vivere l'uno senza l'altro. E' una dipendenza a due vie

Riepilogo UML

Schermata 2024-01-17 alle 03.02.21.png

Rendere configurabile il valore orario

Adesso che abbiamo a disposizione un po' di risorse e abbiamo visto un esempio pratico implementate voi la logica degli altri due punti che erano stati richiesti:

  • rendere configurabile il valore orario
  • avere la possibilità di definire un valore minimo di durata sotto il quale il valore fatturabile è 0

Il valore orario deve essere preso da delle configurazioni legate all'utente. Attualmente non abbiamo un utente quindi dobbiamo creare il sistema predisposto ma falsificare l'origine dei risultati.

Le impostazioni sono nel formato:

interface HourlyRateSettings {
	hourlyRate: number;
}

interface MinBillableSettings {
	minDuration: number;
}

class AmountSettings implements HourlyRateSettings, MinBillableSettings {
	hourlyRate: number;
	minDuration: number;
}