This post is part of the series SOLID Wash Tunnel.
Introduction
This is the third and final post of the mini-series on the Fluent Builder pattern. If you have not read the previous article, I would highly suggest you to do so.
We will keep the UML diagram of the builder pattern as a point of reference.
Invoice
When the wash session is finished, the client receives an invoice. The process of building an invoice is somewhat complex.
- The invoice will look slightly different depending wether the client is an individual or company.
- The total price of the session will change depending on the selected wash program but also on a potential discount.
- The client can specify its preferred currency to get the invoice in, which requires exchange rate conversions.
public class Invoice
{
public string Recepient { get; set; }
public Money Price { get; set; }
public IWashProgram WashProgram { get; set; }
}
Informations like the program type, name of the client, and the preferred currency, all have already been collected by the UserPanel
builder, and have been transmitted to IMemory
via ISignalTransmitter
.
Based on the above mentioned informations, we compose an invoice which includes:
- Recepient's name.
- Selected program name.
- Detailed summary of wash steps applied, and their prices.
- Total price.
- Applied discount.
The InvoiceBuilder
will be invoked internally at the end of the wash session, not by the client!
Implementation of Invoice builder
The main entry point for this builder is the IInvoiceBuilder
interface. Here we specify wether the client is an individual or company.
public interface IInvoiceBuilder
{
IIndividualNamePicker CreateForIndividual();
ICompanyNamePicker CreateForCompany();
}
Depending on the selected choice we move onto getting the clients name. If the client is an individual, we ask for his/her first and last name. If the client represents a company, we ask for the company name.
public interface IIndividualNamePicker
{
IProgramSelector WithName(string firstName, string lastName);
}
public interface ICompanyNamePicker
{
IProgramSelector WithName(string companyName);
}
We specify the selected wash program. Regardless if its built-in or custom.
public interface IProgramSelector
{
ICurrencyPicker Select(IWashProgram program);
}
We specify the clients preferred currency to display the invoice on.
public interface ICurrencyPicker
{
IAmountCalculator Choose(Currency currency);
}
We preceed to calculate the total amount owned. We also internally apply the discount.
public interface IAmountCalculator
{
IInvoicePrinter Calculate();
}
At the end we proceed by invoking Build
, which will print the invoice or specifically return a string
version of the invoice. This in turn will be passed over via the callback specified by the client, when he/she started the wash session via the User Panel builder.
public interface IInvoicePrinter
{
string Build();
}
All of the above mentioned interfaces are implemented by the InvoiceBuilder
class. The builder has a dependency on ICurrencyRateConverter
to convert the prices of the wash steps to the clients preferred currency. It also has a dependency on IPriceCalculatorFactory
to get hold onto an IPriceCalculator
, depending on the customer type.
A private
field of type Invoice
is initialized within the constructor of the builder, and than enriched with information as the building process progresses.
Tip: Declare the builder implementation as internal, as well as all the interfaces.
I suggest declaring the builder implementation as internal
(granted if the programming language of choice supports it). But also all the mentioned interfaces!
The client will not use the builder, and as opposed to the two previous builders, it will not even use the interfaces, as the building of the invoice is handled internally.
For sake of simplicity we will leave it as public
.
public class InvoiceBuilder : IInvoiceBuilder,
IIndividualNamePicker, ICompanyNamePicker, IProgramSelector,
ICurrencyPicker, IAmountCalculator, IInvoicePrinter
{
private int _discount;
private CustomerType _customerType;
private Currency _currency;
private readonly Invoice _invoice;
private readonly IWashStepTracker _tracker;
private readonly ICurrencyRateConverter _converter;
private readonly IPriceCalculatorFactory _calculatorFactory;
public InvoiceBuilder(
IWashStepTracker tracker,
ICurrencyRateConverter converter,
IPriceCalculatorFactory calculatorFactory)
{
_invoice = new Invoice();
_tracker = tracker;
_converter = converter;
_calculatorFactory = calculatorFactory;
}
public IIndividualNamePicker CreateForIndividual()
{
_customerType = CustomerType.Individual;
return this;
}
public ICompanyNamePicker CreateForCompany()
{
_customerType = CustomerType.Company;
return this;
}
public IProgramSelector WithName(string firstName, string lastName)
{
_invoice.Recepient = $"{firstName} {lastName}";
return this;
}
public IProgramSelector WithName(string companyName)
{
_invoice.Recepient = companyName;
return this;
}
public ICurrencyPicker Select(IWashProgram program)
{
_invoice.WashProgram = program;
return this;
}
public IAmountCalculator Choose(Currency currency)
{
_currency = currency;
return this;
}
public IInvoicePrinter Calculate()
{
IPriceCalculator calculator = _calculatorFactory.Create(_customerType);
Money price = calculator.Calculate(_invoice.WashProgram, _currency);
foreach (var washStep in _invoice.WashProgram.GetWashSteps())
{
if (!_tracker.HasStepBeenApplied(washStep))
{
Money washStepPrice = washStep.Price;
if (price.Currency != washStep.Price.Currency)
{
washStepPrice = _converter.Convert(washStep.Price, _currency);
}
price -= washStepPrice;
}
}
_invoice.Price = price;
_discount = calculator.Discount;
return this;
}
public virtual string Build()
{
var builder = new StringBuilder();
builder.AppendLine($"Recepient: {_invoice.Recepient}");
builder.AppendLine($"Program type: {_invoice.WashProgram.Name}");
builder.AppendLine("-----------------------------");
foreach (var washStep in _invoice.WashProgram.GetWashSteps())
{
if (_tracker.HasStepBeenApplied(washStep))
{
builder.AppendLine($" * {washStep.GetDescription()} - {_converter.Convert(washStep.Price, _currency)}");
}
}
builder.AppendLine("-----------------------------");
builder.AppendLine($"Total price: {_invoice.Price}");
builder.AppendLine($"Applied discount: {_discount}%");
return builder.ToString();
}
}
Price Calculator factory
Since we have already elaborated the Simple Factory pattern. We will not go in-depth in this article, but only showcase the implementation of IPriceCalculatorFactory
.
public class PriceCalculatorFactory : IPriceCalculatorFactory
{
private readonly Lazy<IDictionary<CustomerType, Func<IPriceCalculator>>> _calculatorsMap;
public PriceCalculatorFactory(IDictionary<CustomerType, Func<IPriceCalculator>> calculators)
{
_calculatorsMap = new Lazy<IDictionary<CustomerType, Func<IPriceCalculator>>>(calculators);
}
public IPriceCalculator Create(CustomerType type)
{
if (!_calculatorsMap.Value.TryGetValue(type, out Func<IPriceCalculator> _func))
{
throw new NotSupportedException($"No calculator was found for customer type {type}");
}
return _func.Invoke();
}
}
The different types of IPriceCalculator
are loaded from the ConfigMap
class.
internal static class ConfigMap
{
internal static IDictionary<CustomerType, Func<IPriceCalculator>>
GetPriceCalculators(ICurrencyRateConverter converter) =>
new Dictionary<CustomerType, Func<IPriceCalculator>>()
{
{ CustomerType.Individual, () => new IndividualPriceCalculator(converter) },
{ CustomerType.Company, () => new CompanyPriceCalculator(converter) }
};
}
And the service registrations are performed like follows.
public static IContainer AddWashTunnel(this IContainer container)
{
ICurrencyRateConverter converter = new CurrencyRateConverter(legacyConverter, ConfigMap.GetLegacyCurrencies());
container.AddSingleton(() => converter);
container.AddSingleton<IPriceCalculatorFactory>(() => new PriceCalculatorFactory(ConfigMap.GetPriceCalculators(converter)));
}
Invoking the builder
The InvoiceBuilder
is invoked through the IInvoiceBuilder
interface from within the VehicleReadySignalHandler
which is located in the SOLIDWashTunnel
project (so it is invoked internally).
The fluent interfaces allows us to split the code, based on the conditional flow.
- We declare the variable
selector
, which is of typeIProgramSelector
and initialize it asnull
. - We try to get "ICIES" (Individual Customer Info Entered Signal) from memory. If its not available we try to get "CCIES" (Company Customer Info Entered Signal).
- In either case, we use
_invoiceBuilder
and start building until we get back anIProgramSelector
, which in turn we assign it toselector
. - If we can not retrieve the customer info or the selected wash program info (for whatever reason) we throw an
InvalidOperationException
. - Otherwise we use
selector
to finalize the rest of the steps of the invoice builder, which at the end returns the invoice as astring
.
- If we can not retrieve the customer info or the selected wash program info (for whatever reason) we throw an
public class VehicleReadySignal : ISignal
{
private class VehicleReadySignalHandler : ISignalHandler<VehicleReadySignal>
{
private readonly IMemory _memory;
private readonly IInvoiceBuilder _invoiceBuilder;
public VehicleReadySignalHandler(
IMemory memory,
IInvoiceBuilder invoiceBuilder)
{
_memory = memory;
_invoiceBuilder = invoiceBuilder;
}
public void Handle(VehicleReadySignal signal)
{
_memory.TryGet("VWSS", out VehicleWashingStartedSignal _signal);
_signal.InvoiceCallback.Invoke(GenerateInvoiceReport());
_memory.Flush();
}
private string GenerateInvoiceReport()
{
IProgramSelector selector = null;
if (_memory.TryGet("ICIES", out CustomerInfoEnteredSignal info))
{
var individualInfo = info as IndividualCustomerInfoEnteredSignal;
selector = _invoiceBuilder
.CreateForIndividual()
.WithName(individualInfo.FirstName, individualInfo.LastName);
}
else if (_memory.TryGet("CCIES", out info))
{
var companyInfo = info as CompanyCustomerInfoEnteredSignal;
selector = _invoiceBuilder
.CreateForCompany()
.WithName(companyInfo.CompanyName);
}
if (selector != null)
{
if (_memory.TryGet("WPSS", out WashProgramSelectedSignal _signal))
{
return selector
.Select(_signal.Program)
.Choose(info.PreferredCurrency)
.Calculate()
.Build();
}
}
throw new InvalidOperationException("Can not generate invoice because some critical informations about the wash session are missing.");
}
}
}
Printed invoice
With the following configuration applied.
class Program
{
static void Main(string[] args)
{
IContainer container = new Container();
IVehicle vehicle = new DirtyMetallicCar();
var panel = container.GetService<IUserPanel>();
var builder = container.GetService<ICustomWashProgramBuilder>();
var customProgram = builder
.Add(WashStepType.ChasisAndWheelWashing)
.Add(WashStepType.Shampooing)
.Add(WashStepType.HighPressureWashing)
.Add(WashStepType.SingleColorFoaming)
.Add(WashStepType.HighPressureWashing)
.Add(WashStepType.Waxing)
.Add(WashStepType.AirDrying)
.Build();
panel.SelectCustomizedProgram(customProgram)
.AsCompany("Ledjon SoftTech", Currency.USD)
.Start(vehicle, PrintInvoice());
}
static Action<string> PrintInvoice() => (content) =>
{
Console.OutputEncoding = System.Text.Encoding.UTF8;
Console.WriteLine("\nInvoice Report");
Console.WriteLine("*************************");
Console.WriteLine(content);
Console.WriteLine("\n\n\n");
};
}
We get the following output for the invoice, after the program has finished running.
Invoice Report
*************************
Recepient: Ledjon SoftTech
Program type: Custom
-----------------------------
* Chasis & wheels washing - 1.5$
* Shampooing - 0.8$
* High water pressure washing - 0.3$
* Foaming using a single color foam - 1.1$
* High water pressure washing - 0.3$
* Waxing - 2.2$
* Air drying - 0.5$
-----------------------------
Total price: 5.36$
Applied discount: 20%
Analogy to the builder pattern
Lets connect the concepts defined in the UML diagram of the builder pattern, to the InvoiceBuilder
.
- The joint effect of
IInvoiceBuilder
,IIndividualNamePicker
, ... ,IInvoicePrinter
interfaces is analogous toBuilder
. It represents the abstraction, which in our case has been further seggragated into multiple interfaces to promote narrow focus and specialized responsibilities. InvoiceBuilder
is analogous toConcreteBuilder
. It represents the concrete implementation ofBuilder
, or in our case the implementation of all the interfaces mentioned above.VehicleReadySignalHandler
(or calling code) is analogous toDirector
.- The returned
string
a.k.a printed invoice is analogous toProduct
.
Principles
Principle | Applied | Explanation |
---|---|---|
SRP | β | InvoiceBuilder has one responsibility, that is to build an invoice. |
OCP | β | InvoiceBuilder is closed for modification, but open for extension. The class does not have to change if new wash steps, wash programs, price calculators, or currencies are introduced. |
LSP | β | There is no inheritance involved, so LSP has no applicability. |
ISP | β | The Builder abstraction has been seggragated into multiple interfaces IInvoiceBuilder , IIndividualNamePicker , ... , IInvoicePrinter . |
DIP | β | The high level module InvoiceBuilder does not depend on the low level modules which are any derived types of PriceCalculator , and CurrencyRateConverter . It dependes on the abstractions IPriceCalculatorFactory , and ICurrencyRateConverter respectively. |
Continue the series on Chain of Responsibility.
If you found this article helpful please give it a share in your favorite forums π.
The solution project is available at GitHub.