I am working currently on a project that requires a series (almost 86) calculations to run based on a user data input. The problem is that each calculation has a series of requirements:
- Should be able to hold a
version
variable to be able to distinguish changes on each calculation algorithm implementation. This way, anytime we modify an algorithm, we know which version was used on specific calculation. - Should be able to load specific data from other modules within the application (namely, we have 8 entities) so each one can choose the information necessary for its operation.
- Should be able to determine if it's "runnable", and by which we would write a function (?) that verifies that the data extracted (from the previous requirement) meets some custom criteria for each calculation that guarantees the algorithm will be executed correctly.
- Should have each a different algorithm implementation.
- Generate a series of execution metrics (logs) and store them, such as data fetching time, algorithm execution time and the
sampleSize
which refers to as the amount of data loaded for each specific calculation to run.
Currently, what I've done is: created an abstract class Calculation
with this structure:
abstract class Calculation<T, F> {
/**
* Logging Variables.
*/
private initialDataFetchTime: Date;
private finalDataFetchTime: Date;
private initialAlgorithmTime: Date;
private finalAlgorithmTime: Date;
// Final result holding variable.
private finalResult: T;
// The coverage status for this calculation.
private coverage: boolean;
// Data to use within the algorithm.
private data: F;
// The version of the Calculation.
public abstract version: string;
// The form data from the User to be used.
public static formData: FormData;
/**
* This is the abstract function to be implemented with
* the operation to be performed with the data. Always
* called after `loadData()`.
*/
public abstract async algorithm(): Promise<T>;
/**
* This function should implement the data fetching
* for this particular calculation. This function is always
* called before `calculation()`.
*/
public abstract async fetchData(): Promise<F>;
/**
* This is the abstract function that checks
* if enough information is met to perform the
* calculation. This function is called always
* after `loadData()`.
*/
public abstract async coverageValidation(): Promise<boolean>;
/**
* This is the public member function that is called
* to perform the data-fetching operations of the
* calculation. This is the first function to call.
*/
public async loadData(): Promise<void> {
// Get the initial time.
this.initialDataFetchTime = new Date();
/**
* Here we run the data-fetching implementation for
* this particular calculation.
*/
this.data = await this.fetchData();
// Store the final time.
this.finalDataFetchTime = new Date();
}
/**
* This is the public member function that is called
* to perform the calculation on this field. This is
* the last function to be called.
*/
public async calculation(): Promise<T> {
// Get the initial time.
this.initialAlgorithmTime = new Date();
/**
* Here we run the algorithmic implementation for
* this particular calculation.
*/
this.finalResult = await this.algorithm();
// Store the final time.
this.finalAlgorithmTime = new Date();
// Return the result.
return this.finalResult;
}
/**
* This is the public member function that is called
* to perform the coverage-checking of this calculation.
* This function should be called after the `loadData()`
* and before `calculation()`.
*/
public async coverageCheck(): Promise<boolean> {
// Execute the check function.
this.coverage = await this.coverageValidation();
// Return result.
return this.coverage;
}
/**
* Set FormData statically to be used across calculations.¡
*/
public static setFormData(formData: FormData): FormData {
// Store report.
this.formData = formData;
// Return report.
return this.formData;
}
/**
* Get the coverage of this calculation.
*/
public getCoverage(): boolean {
return this.coverage;
}
/**
* Get the data for this calculation.
*/
public getData(): F {
return this.data;
}
/**
* Get the result for this calculation.
*/
public getResult(): T {
return this.finalResult;
}
/**
* Function to get the class name.
*/
private getClassName(): string {
return this.constructor.name;
}
/**
* Function to get the version for this calculation.
*/
private getVersion(): string { return this.version; }
/**
* Get all the Valuation Logs for this Calculation.
*/
public async getValuationLogs(): Promise<CreateValuationLogDTO[]> {
// The array of results.
const valuationLogs: CreateValuationLogDTO[] = [];
// Log the time the algorithm took to execute.
valuationLogs.push({
report: Calculation.formData,
calculation: this.getClassName(),
metric: 'Algorithm Execution Time',
version: this.getVersion(),
value:
this.initialAlgorithmTime.getTime() - this.finalAlgorithmTime.getTime(),
});
// Log the time to fetch information.
valuationLogs.push({
report: Calculation.formData,
calculation: this.getClassName(),
metric: 'Data Fetch Load Time',
version: this.getVersion(),
value:
this.initialDataFetchTime.getTime() - this.finalDataFetchTime.getTime(),
});
// Sample size is calculated and not an issue for this matter.
// Return the metrics.
return valuationLogs;
}
}
And then, created subsequent classes for each calculation that extend the previous class, such as:
export class GeneralArea extends Calculation<number, GeneralAreaData> {
/**
* Versioning information.
* These variable hold the information about the progress done to this
* calculation algorithm. The `version` field is a SemVer field which
* stores the version of the current algorithm implementation.
*
* IF YOU MAKE ANY MODIFICATION TO THIS CALCULATION, PLEASE UPDATE THE
* VERSION ACCORDINGLY.
*/
public version = '1.0.0';
// Dependencies.
constructor(private readonly dataSource: DataSource) {
super();
}
// 1) Fetch Information
public async fetchData(): Promise<GeneralAreaData> {
// Query the DB.
const dataPoints = this.dataSource.getInformation(/** **/);
// Return the data object.
return {
mortgages: dataPoints,
};
}
// 2) Validate Coverage.
public async coverageValidation(): Promise<boolean> {
// Load data.
const data: GeneralAreaData = this.getData();
// Validate to be more than 5 results.
if (data.mortgages.length < 5) {
return false;
}
// Everything correct.
return true;
}
// 3) Algorithm
public async algorithm(): Promise<number> {
// Load data.
const data: GeneralAreaData = this.getData();
// Perform operation.
const result: number = await Math.min.apply(
Math,
data.mortgages.map(mortgage => mortgage.price),
);
// Return the result.
return result;
}
}
/**
* Interface that holds the structure of the data
* used for this implementation.
*/
export interface GeneralAreaData {
// Mortages based on some criteria.
mortages: SomeDataEntity;
}
The idea is to allow us to perform three basic operations:
- Load the data for every calculation.
- Validate coverage for every calculation.
- If the previous step returns a general "true", run calculations.
However, this pattern has raised some issues as the FormData
(the information the user uploads) is stored statically, which means that if some calculation is already running and another user performs an upload, I cannot set the FormData because it will cause the other User's calculations to go nuts. However passing the FormData
to each function constructor seems like a lot of work (if you feel like this should be the way, I have no fear of writing code ;) )
Perhaps is this quarantine, however, am I not seeing something here? Currently, the final execution looks something like this:
public performCalculation(formData: FormData): Promise<FormDataWithCalculations> {
// Set general form data.
Calculation.setFormData(formData); // <--- Error in subsequent requests :(
// Instance Calculations.
const generalAreaCalculation: GeneralAreaCalculation = new GeneralAreaCalculation(/** data service **/);
// 85 more instantiations...
// Load data for Calculations.
try {
await Promise.all([
generalAreaCalculation.loadData(),
// 85 more invocations...
]);
} catch(dataLoadError) { /** error handling **/ }
// Check for coverage.
const coverages: boolean[] = await Promise.all([
generalAreaCalculation.coverageCheck(),
// 85 more coverage checks...
]);
// Reduce coverage.
const covered: boolean = coverages.reduce((previousValue, coverage) => coverage && previousValue, true);
// Check coverage.
if (!covered) { /** Throw exception **/ }
// Perform calculations!
const result: FormDataWithCalculations = new FormDataWithCalculations(formData);
try {
result.generalAreaValue = generalAreaCalculation.calculation();
// 85 more of this.
} catch (algorithmsError) { /** error handling ***/ }
/*
(( Here should go the log collecting and storing, for each of the 85 calculations ))
*/
// Return processed information.
return result;
}
I am not afraid of writing too much code if it means for it to be reusable, maintainable, and more importantly, able to be testable (oh yes, test each calculation to ensure it does what it is supposed to do in normal and edge cases, that's why classes were my approach so each one would have attached a test), however I am completely overwhelmed with writing this tremendous amount of code instead of just writing 85 functions (which is what is already been used) and call each one of them.
Is there any patterns? Guidance? Advice? Reference? Study material? I can't seem to reduce this code any more but wanted to ask in case someone knows a better pattern for this kind of problems and, in case it is useful, the code in TypeScript (NodeJS with NestJS API) to understand how is everything getting wired.
Thanks in advance and apologies for my awful english!