I was participating in a job selection process and one of the tasks I was asked to do was to develop a take home pay application to let users to calculate their paychecks based on their location. Today I received the feedback, and it stated that:
- Taxes poorly factored
- Low OO knowledge
The software requirements are stated below:
Write a program which can be used to calculate the take home pay for employees based on their location. The application should be a console app which prompts the user for the hourly rate and hours worked for the employee.
Please enter the hours worked: Please enter the hourly rate: Please enter the employee’s location: The program will output their gross along with a list of deductions along with a net amount.
Employee location: Ireland
Gross Amount: €x
Less deductions
Income Tax : €x Universal Social Charge: €x Pension: €x Net Amount: €x
Requirements
As a payroll user I would like to see a gross amount calculation for an employee’s salary.
Given the employee is paid €10 per hour, when the employee works 40 hours, the Gross amount is €400
As a payroll user I would like to see deductions charged for employees located in Ireland
Given the employee is located in Ireland, income tax at a rate of 25% for the first €600 and 40% thereafter Given the employee is located in Ireland, a Universal social charge of 7% is applied for the first €500 euro and 8% thereafter Given the employee is located in Ireland, a compulsory pension contribution of 4% is applied
As a payroll user I would like to see deductions charged for employees located in Italy
Given the employee is located in Italy, income tax at a flat rate of 25% is applied Given the employee is located in Italy, an INPS contribution of is applied based on gross salary. This is charged at 9% for the first €500 and increases by .1% over every €100 thereafter.
As a payroll user I would like to see deductions charged for employees located in Germany
Given the employee is located in Germany, income tax at a rate of 25% is applied on the first €400 and 32% thereafter Given the employee is located in Germany, a compulsory pension contribution of 2% is applied
I really cannot understand why my code was so badly evaluated, so it would be wonderful to have some of you checking out what could have went so wrong.
Just to make it clear, I would like to have some of you guys checking out the code to try to find out some design flaws that could have motivated the bad evaluation I received from the tech team.
The code I wrote can be found, along with the requirements, here.
Here follows some important parts of the code:
Base class PayCheckCalculator
using Domain.Entities;
namespace Domain.Interface
{
public abstract class PayCheckCalculator
{
protected void CalculateGrossAmount(PayCheck payCheck, int hoursWorked, decimal hourlyRate)
{
payCheck.GrossSalary = hoursWorked * hourlyRate;
}
protected abstract void CalculateDeductions(PayCheck payCheck);
protected void CalculateNetAmount(PayCheck payCheck)
{
decimal netAmount = payCheck.GrossSalary;
foreach (var deduction in payCheck.Deductions)
{
netAmount -= deduction.Amount;
}
payCheck.NetSalary = netAmount;
}
public PayCheck Calculate(int hoursWorked, decimal hourlyRate)
{
PayCheck payCheck = new PayCheck();
CalculateGrossAmount(payCheck, hoursWorked, hourlyRate);
CalculateDeductions(payCheck);
CalculateNetAmount(payCheck);
return payCheck;
}
}
}
Concrete class GermanyPayCheckCalculator
using Domain.Entities;
using Domain.Interface;
namespace PayCheckCalculators.Calculators
{
public class GermanyPayCheckCalculator: PayCheckCalculator
{
protected override void CalculateDeductions(PayCheck payCheck)
{
payCheck.Deductions.Add(CalculateIncomeDeduction(payCheck));
payCheck.Deductions.Add(CalculateCompulsoryPensionDeduction(payCheck));
}
private Deduction CalculateIncomeDeduction(PayCheck payCheck)
{
string deductionDesc = "Income tax";
decimal amount = 0.0m;
decimal surplus = payCheck.GrossSalary - 400;
if (surplus <= 0)
{
amount = payCheck.GrossSalary * 0.25m;
}
else
{
amount = 400m * 0.25m;
amount += surplus * 0.32m;
}
return new Deduction(deductionDesc, amount, Deduction.DeductionType.INCOME_DEDUCTION);
}
private Deduction CalculateCompulsoryPensionDeduction(PayCheck payCheck)
{
string deductionDesc = "Pension";
return new Deduction(deductionDesc, payCheck.GrossSalary * 0.02m, Deduction.DeductionType.PENSION_DEDUCTION);
}
}
}
Concrete class IrelandPayCheckCalculator
using Domain.Interface;
using Domain.Entities;
namespace PayCheckCalculators.Calculators
{
public class IrelandPayCheckCalculator : PayCheckCalculator
{
protected override void CalculateDeductions(PayCheck payCheck)
{
payCheck.Deductions.Add(CalculateIncomeDeduction(payCheck));
payCheck.Deductions.Add(CalculateUniversalSocialChargeDeduction(payCheck));
payCheck.Deductions.Add(CalculateCompulsoryPensionDeduction(payCheck));
}
private Deduction CalculateIncomeDeduction(PayCheck payCheck)
{
string deductionDesc = "Income tax";
decimal amount = 0.0m;
decimal surplus = payCheck.GrossSalary - 600;
if (surplus <= 0)
{
amount = payCheck.GrossSalary * 0.25m;
}
else
{
amount = 600m * 0.25m;
amount += surplus * 0.4m;
}
return new Deduction(deductionDesc, amount, Deduction.DeductionType.INCOME_DEDUCTION);
}
private Deduction CalculateUniversalSocialChargeDeduction(PayCheck payCheck)
{
string deductionDesc = "Universal social charge";
decimal amount = 0.0m;
decimal surplus = payCheck.GrossSalary - 500;
if (surplus <= 0)
{
amount = payCheck.GrossSalary * 0.07m;
}
else
{
amount = 500m * 0.07m;
amount += surplus * 0.08m;
}
return new Deduction(deductionDesc, amount, Deduction.DeductionType.UNIVERSAL_SOCIAL_DEDUCTION); ;
}
private Deduction CalculateCompulsoryPensionDeduction(PayCheck payCheck)
{
string deductionDesc = "Pension";
return new Deduction(deductionDesc, payCheck.GrossSalary * 0.04m, Deduction.DeductionType.PENSION_DEDUCTION);
}
}
}
Concrete class ItalyPayCheckCalculator
using Domain.Interface;
using Domain.Entities;
namespace PayCheckCalculators.Calculators
{
public class ItalyPayCheckCalculator : PayCheckCalculator
{
protected override void CalculateDeductions(PayCheck payCheck)
{
payCheck.Deductions.Add(CalculateIncomeDeduction(payCheck));
payCheck.Deductions.Add(CalculateInpsDeduction(payCheck));
}
private Deduction CalculateIncomeDeduction(PayCheck payCheck)
{
string deductionDesc = "Income tax";
return new Deduction(deductionDesc, payCheck.GrossSalary * 0.25m, Deduction.DeductionType.INCOME_DEDUCTION); ;
}
private Deduction CalculateInpsDeduction(PayCheck payCheck)
{
string deductionDesc = "INPS";
decimal amount = 0.0m;
decimal surplus = payCheck.GrossSalary - 500m;
if (surplus <= 0)
{
amount = payCheck.GrossSalary * 0.09m;
}
else
{
amount = 500 * 0.09m;
while (surplus >= 100)
{
amount += 1;
surplus -= 100;
}
}
return new Deduction(deductionDesc, amount, Deduction.DeductionType.INPS_DEDUCTION); ;
}
}
}
Abstract factory class
namespace Domain.Interface
{
public abstract class AbstractPayCheckCalculatorFactory<T>
{
public abstract T CreatePayCheckCalculator(string location);
}
}
Concrete factory class
using Domain.Interface;
using PayCheckCalculators.Calculators;
using System;
namespace PayCheckCalculators.Factory
{
public class PayCheckCalculatorFactory: AbstractPayCheckCalculatorFactory<PayCheckCalculator>
{
public override PayCheckCalculator CreatePayCheckCalculator(string location)
{
PayCheckCalculator calculator;
if (location == null)
{
throw new ArgumentNullException("Location cannot be null.");
}
if (location.Length == 0)
{
throw new ArgumentException("Location cannot be empty.");
}
switch (location.ToLower())
{
case "ireland": calculator = new IrelandPayCheckCalculator(); break;
case "italy": calculator = new ItalyPayCheckCalculator(); break;
case "germany": calculator = new GermanyPayCheckCalculator(); break;
default: throw new NotSupportedException("The provided country is not yet supported.");
}
return calculator;
}
}
}
Employee
class
using System;
using System.Collections.Generic;
using System.Linq;
using Domain.Validation;
namespace Domain.Entities
{
public class Employee
{
public string Location { get; private set; }
public int HoursWorked { get; private set; }
public decimal HourlyRate { get; private set; }
public Employee(string location, int hoursWorked = 0, decimal hourlyRate = 0.0m)
{
Location = location;
HoursWorked = hoursWorked;
HourlyRate = hourlyRate;
var errors = Validate();
if (errors.Count > 0)
{
var except = new ArgumentException();
except.Data.Add("errors", errors);
throw except;
}
}
private ICollection<ValidationError> Validate()
{
var errors = new List<ValidationError>();
errors.AddRange(ValidateHoursWorked());
errors.AddRange(ValidateHourlyRate());
errors.AddRange(ValidateLocation());
return errors;
}
//TODO: I don't check for an upper limit for now because it must be discussed further with the PO.
private ICollection<ValidationError> ValidateHoursWorked()
{
var errors = new List<ValidationError>();
if (HoursWorked < 0)
{
String description = "Error: Hours Worked cannot be negative.";
errors.Add(new ValidationError(ValidationError.ErrorCode.IMPUT_OUT_OF_RANGE, description));
}
return errors;
}
//TODO: I don't check for an upper limit for now because it must be discussed further with the PO.
private ICollection<ValidationError> ValidateHourlyRate()
{
var errors = new List<ValidationError>();
if (HourlyRate < 0)
{
String description = "Error: Hourly Rate cannot be negative.";
errors.Add(new ValidationError(ValidationError.ErrorCode.IMPUT_OUT_OF_RANGE, description));
}
return errors;
}
private ICollection<ValidationError> ValidateLocation()
{
var errors = new List<ValidationError>();
String description = "";
if (Location == null)
{
description = "Error: Location cannot be null.";
errors.Add(new ValidationError(ValidationError.ErrorCode.IMPUT_NULL, description));
}
else if (Location.Length == 0)
{
description = "Error: Location cannot be empty.";
errors.Add(new ValidationError(ValidationError.ErrorCode.IMPUT_EMPTY, description));
}
else if (!Location.All(c => char.IsLetter(c)))
{
description = "Error: Location can only contain letters.";
errors.Add(new ValidationError(ValidationError.ErrorCode.IMPUT_OUT_OF_FORMAT, description));
}
return errors;
}
}
}
Program
class
using Domain.Entities;
using Domain.Interface;
using Domain.Validation;
using PayCheckApp.View;
using PayCheckCalculators.Factory;
using System;
using System.Collections.Generic;
namespace PayCheckApp
{
public class Program
{
public void Run(AbstractPayCheckCalculatorFactory<PayCheckCalculator> calculatorFactory)
{
bool tryAgain = false;
do
{
var myUserInterface = new PayCheckCalculatorView();
Employee employee;
try
{
employee = myUserInterface.GetEmployee();
myUserInterface.ShowResult(calculatorFactory.CreatePayCheckCalculator(employee.Location).Calculate(employee.HoursWorked, employee.HourlyRate));
}
catch (ArgumentException ex)
{
if (ex.Data.Contains("errors"))
{
var errors = ex.Data["errors"] as ICollection<ValidationError>;
if (errors != null)
{
myUserInterface.ShowErrors(errors);
}
}
else
{
myUserInterface.ShowError(ex.Message);
}
}
catch (Exception ex)
{
myUserInterface.ShowError(ex.Message);
}
finally
{
tryAgain = myUserInterface.IsConcluded();
}
}
while (tryAgain);
}
static void Main(string[] args)
{
(new Program()).Run(new PayCheckCalculatorFactory());
}
}
}
So, let me explain the software architecture as I see. First, I splited the codebase in 4 different visual studio projects, as listed below:
Domain (Class Library):
Contains the domain entities, as Employee, Paycheck and Deduction. It also contains a Validation class that is used to validade the state of different classes in the system and two abstract classes used as superclasses for the calculators and calculator factories.
PayCheckCalculators
(Class Library):Contains the paycheck calculators. As the requirements state, each country have its own sort of deductions. However, the gross and net salary calculation is universal. Gross salary is the simple multiplication of
HoursWorked
andhourlyRate
. The net salary is simply gross salary minus the summation of all deductions.Consequently, the calculation of gross and net salary is done in the abstract class
PayCheckCalculator
, while the deductions are calculated in the appropriate child classes:ItalyPayCheckCalculator
,GermanyPayCheckCalculator
andIrelandPayCheckCalculator
.The superclass
PayCheckCalculator
resides in theDomain
project, while the specializations reside in thePayCheckCalculators
project. Here I believe I could improve the architecture creating an Interface containing all public methods of thePayCheckCalculator
abstract class, and I would make this abstract class to extend the interface. So, I could put the interface in the domain project and the abstract class in the calculators project, avoidingPaycheck
calculation implementation details in the domain project. Although it would be an improvement, I don't think it is a life or death case.Tests project (Class Library):
One of the requirements was to use TDD in the development process. So I began coding by the tests and in the end I covered about 97% of the codebase with unit tests.
PayCheckCalculatorApp
(Console Application):This project contains the presentation part of the software. There are only two classes in the project:
Program
andPayCheckCalculatorView
. TheProgram
class works as a controller. It instanciates the view and ask for an Employee.Then the view then asks the user for the required information and creates an Employee using the provided info. Once the controller has an instance of Employee, it requests the factory to create a new
PayCheckCalculator
. The concrete factory is injected to the controller (I don't use DI containers). Once the proper calculator is instantiated, the controller calls the calculate method.Finally, the controller checks for any thrown exceptions and in case it receives one, it call the showError/ShowErrors method of the view.
Let me summing things up comparing to SOLID principles:
Single Responsibility
Each class has one reason to change. For example, the
Employee
class can only change if the business rules change, because all it does is to validate the user inputs, as hours worked, hourly rate and location (country).In addition, each calculator can only change if the rules for that precise calculator change. For example, if from now on Ireland starts to charge a lower income tax or change the rules to apply this income tax, then the
IrelandPayCheckCalculator
would have to be update (or I new class should be designed, as I explain later). So, I believe I achieved to comply with single responsibility principle.Open/Closed Principle
Let's say I want to implement a new calculator, for example
BrazilPayCheckCalculator
. All I have to do is to create a subclass ofPayCheckCalculator
and implement theCalculateDeductions
method. In addition, I would need to update the concrete factory class or create a subclass ofAbstractPayCheckCalculatorFactory
(or even of the concretePayCheckCalculatorFactory
) and implement the logic to return an instance ofBrazilPayCheckCalculator
.Changes can be also accomplished like in the creation of new calculators. In other words, if I need to change the requirements of a given calculator, I could simply create a new subclass of
PayCheckCalculator
and implement the required logic.Summing all these up, the code complies with open/closed principle.
Liskov Substitution Principle
The
Program
class uses the calculator in a polymorphic way. First, it delegates to a factory class the creation of the proper calculator instance. In addition, the factory class returns the instance using the abstract superclassPayCheckCalculator
. TheProgram
class then calls theCalculate
method in the returned instance.Consequently, we can replace this instance by any child of
PayCheckCalculator
and the software will work seamlessly.The same is not true for the relationship between the Program and View classes. However, I don't think it is a core concern.
Consequently, to the best of my knowledge, the code complies with the Liskov Substitution principle.
Interface Segregation Principle
Well, as in my code I don't have a case where a class is forced to implement methods that it doesn't use, I believe that this principle also holds in my codebase.
Inversion of Dependency
This part could be improved as I said in the beginning of the post. Currently, all my calculator classes derive from an abstract class that actually implements the Gross and Net salary calculation and let the subclasses calculate the deductions.
Consequently, the clients of a calculator don't depend on the concrete calculator, but instead depend on the abstract class. In other words, the client classes are obliviated from the specifics of each calculator. Even better, because the abstract class resides in the domain assembly, no references need to be made to the calculators assembly.
As I said before, this could be improved. Because the abstract class has some implementation details, it would be better to create an interface and make the abstract class implement (partially) it.
Because all of these, I believe my code complies with all solid principles. It is true it can be improved, but I don't believe it could be evaluated as a code written by a developer with low knowledge of OO design principles.