This post is part of the series SOLID Wash Tunnel.

Introduction

This is the second 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.

UML class diagram. [source]

Implementation of Custom Wash Program builder

The client has the option to build a wash program itself. Any wash program exposes a user-friendly name, and a collection of wash steps that it offers.

public interface IWashProgram
{
string Name { get; }
IEnumerable<IWashStep> GetWashSteps();
}

There are already some built-in wash programs from which the client can choose from. They expose their metadata and the wash steps that they offer, but the steps can not be added, removed or modified.

public class FastWashProgram : IWashProgram
{
public string Name => "Fast";

private readonly IWashStepFactory _washStepFactory;

public FastWashProgram(IWashStepFactory washStepFactory)
{
_washStepFactory = washStepFactory;
}

public IEnumerable<IWashStep> GetWashSteps() =>
new List<IWashStep>()
{
_washStepFactory.Create(WashStepType.ChasisAndWheelWashing),
_washStepFactory.Create(WashStepType.HighPressureWashing),
_washStepFactory.Create(WashStepType.AirDrying)
};
}

public class EconomicWashProgram : IWashProgram
{
public string Name => "Economic";

private readonly IWashStepFactory _washStepFactory;

public EconomicWashProgram(IWashStepFactory washStepFactory)
{
_washStepFactory = washStepFactory;
}

public IEnumerable<IWashStep> GetWashSteps() =>
new List<IWashStep>()
{
_washStepFactory.Create(WashStepType.ChasisAndWheelWashing),
_washStepFactory.Create(WashStepType.Shampooing),
_washStepFactory.Create(WashStepType.HighPressureWashing),
_washStepFactory.Create(WashStepType.SingleColorFoaming),
_washStepFactory.Create(WashStepType.HighPressureWashing),
_washStepFactory.Create(WashStepType.AirDrying)
};
}

public class AllRounderWashProgram : IWashProgram
{
public string Name => "All rounder";

private readonly IWashStepFactory _washStepFactory;

public AllRounderWashProgram(IWashStepFactory washStepFactory)
{
_washStepFactory = washStepFactory;
}

public IEnumerable<IWashStep> GetWashSteps()
{
foreach (WashStepType type in Enum.GetValues(typeof(WashStepType)))
{
yield return _washStepFactory.Create(type);
}
}
}

The CustomWashProgram in the order hand, by default, has no wash steps to offer. It accepts an IEnumerable<IWashStep> in its constructor, and returns them when they are requested.

public class CustomWashProgram : IWashProgram
{
public string Name => "Custom";

private IEnumerable<IWashStep> _washSteps;

public CustomWashProgram(IEnumerable<IWashStep> washSteps)
{
_washSteps = washSteps;
}

public IEnumerable<IWashStep> GetWashSteps() => _washSteps;
}

In part one of this mini-series, we introduced the IUserPanel interface which contained two methods one of which accepted an IWashProgram, namely SelectCustomizedProgram. This enabled the client to build their own wash program and apply that one to wash their vehicle.

public interface IUserPanel
{
ICustomerInformationCollector SelectBuiltInProgram(ProgramType type);
ICustomerInformationCollector SelectCustomizedProgram(IWashProgram program);
}

The builder interface exposes methods to conveniently add wash steps to their to-be build program, without knowing the internals of the wash steps. The same wash step can be added multiple times. This makes sense because even in some of our built-in programs we have some steps appearing multiple times.

For example, in the "Economic" program, the wash step HighPressureWashing appears twice. Once after Shampooing, and once after SingleColorFoaming.

The interface exposes 4 methods, each with a purpose:

  • The overload of Add accepting a WashStepType enum, is used to add an IWashStep that is built-in.
  • The overload of Add accepting an IWashStep, is used to add a custom IWashStep.
  • AddAll is used to bulk add all available built-in wash steps.
  • Build simply builds the end IWashProgram and returns it to the client.
public interface ICustomWashProgramBuilder
{
ICustomWashProgramBuilder Add(WashStepType type);
ICustomWashProgramBuilder Add(IWashStep washStep);
ICustomWashProgramBuilder AddAll();

IWashProgram Build();
}

public enum WashStepType
{
ChasisAndWheelWashing,
Shampooing,
HighPressureWashing,
SingleColorFoaming,
ThreeColorFoaming,
Waxing,
AirDrying
}

The client builds their wash program through the interface, not through the concrete implementation - ⚠️ Dependency Inversion Principle.

Tip: Declare the builder implementation as internal.

I suggest declaring the builder implementation as internal (granted if the programming language of choice supports it).

The client does not need it, which implies it should not be able to reference it. This would lower the chances of making breaking changes in the future, because there is no consumer of this class.

For sake of simplicity we will leave it as public.

public class CustomWashProgramBuilder : ICustomWashProgramBuilder
{
private readonly List<IWashStep> _washSteps;
private readonly IWashProgramFactory _programFactory;
private readonly IWashStepFactory _washStepFactory;

public CustomWashProgramBuilder(
IWashProgramFactory programFactory,
IWashStepFactory washStepFactory)
{
_washSteps = new List<IWashStep>();
_programFactory = programFactory;
_washStepFactory = washStepFactory;
}


public ICustomWashProgramBuilder Add(WashStepType type)
{
_washSteps.Add(_washStepFactory.Create(type));
return this;
}

public ICustomWashProgramBuilder Add(IWashStep washStep)
{
_washSteps.Add(washStep);
return this;
}

public ICustomWashProgramBuilder AddAll()
{
foreach (WashStepType type in Enum.GetValues(typeof(WashStepType)))
{
_washSteps.Add(_washStepFactory.Create(type));
}

return this;
}


public IWashProgram Build()
{
if (_washSteps.Count == 0)
throw new InvalidOperationException("A custom wash program must have at least one wash step.");

IWashProgram program = _programFactory.Create(ProgramType.Custom, _washSteps.ToArray());
_washSteps.Clear();

return program;
}
}

Preventing mutation after build

Notice how we clear the variable _washSteps upon calling Build. This is important to ensure that no modifications can be made after the custom program has been build.

Take for example the following code.

var builder = container.GetService<ICustomWashProgramBuilder>();

IWashProgram customProgram1 = builder
.Add(WashStepType.ChasisAndWheelWashing)
.Add(WashStepType.Shampooing)
.Build();

IWashProgram customProgram2 = builder
.Add(WashStepType.AirDrying)
.Build();

What would be the total number of wash steps in customProgram1 and customProgram2, if we did not clear _washSteps upon calling Build?

The answer is both customProgram1 and customProgram2 would contain a total of 3 wash steps. The reason is because both use the same builder instance. Which would be an incorrect behaviour!

By clearing _washSteps we ensure that customProgram1 will end up having a total of 2 wash steps (ChasisAndWheelWashing, Shampooing), and customProgram2 will have a total of 1 wash steps (AirDrying).


Wash Program & Wash Step factories

Since we have already elaborated the Simple Factory pattern. We will not go in-depth in this article, but only showcase the implementations of IWashProgramFactory and IWashStepFactory.

public class WashProgramFactory : IWashProgramFactory
{
private readonly Lazy<IDictionary<ProgramType, Func<IWashStep[], IWashProgram>>> _programsMap;

public WashProgramFactory(IDictionary<ProgramType, Func<IWashStep[], IWashProgram>> programs)
{
_programsMap = new Lazy<IDictionary<ProgramType, Func<IWashStep[], IWashProgram>>>(programs);
}

public IWashProgram Create(ProgramType type, params IWashStep[] washSteps)
{
if (!_programsMap.Value.TryGetValue(type, out Func<IWashStep[], IWashProgram> _func))
{
throw new NotSupportedException($"Wash program type {type} is not supported!");
}

return _func.Invoke(washSteps);
}
}


public class WashStepFactory : IWashStepFactory
{
private readonly Lazy<IDictionary<WashStepType, Func<IWashStep>>> _washStepsMap;

public WashStepFactory(IDictionary<WashStepType, Func<IWashStep>> washSteps)
{
_washStepsMap = new Lazy<IDictionary<WashStepType, Func<IWashStep>>>(washSteps);
}

public IWashStep Create(WashStepType type)
{
if (!_washStepsMap.Value.TryGetValue(type, out Func<IWashStep> _func))
{
throw new NotSupportedException($"Wash step type {type} is not supported!");
}

return _func.Invoke();
}
}

The different types of IWashProgram and IWashStep are loaded from the ConfigMap class.

internal static class ConfigMap
{
internal static IDictionary<ProgramType, Func<IWashStep[], IWashProgram>>
GetWashPrograms(IWashStepFactory factory) =>
new Dictionary<ProgramType, Func<IWashStep[], IWashProgram>>()
{
{ ProgramType.Custom, (ws) => new CustomWashProgram(ws) },
{ ProgramType.Fast, _ => new FastWashProgram(factory) },
{ ProgramType.Economic, _ => new EconomicWashProgram(factory) },
{ ProgramType.AllRounder, _ => new AllRounderWashProgram(factory) }
};

internal static IDictionary<WashStepType, Func<IWashStep>>
GetWashSteps() =>
new Dictionary<WashStepType, Func<IWashStep>>()
{
{ WashStepType.ChasisAndWheelWashing, () => new ChasisAndWheelWashing() },
{ WashStepType.Shampooing, () => new Shampooing() },
{ WashStepType.HighPressureWashing, () => new HighPressureWashing() },
{ WashStepType.SingleColorFoaming, () => new SingleColorFoaming() },
{ WashStepType.ThreeColorFoaming, () => new ThreeColorFoaming() },
{ WashStepType.Waxing, () => new Waxing() },
{ WashStepType.AirDrying, () => new AirDrying() }
};
}

And the service registrations are performed like follows.

public static IContainer AddWashTunnel(this IContainer container)
{
IWashStepFactory washStepFactory = new WashStepFactory(ConfigMap.GetWashSteps());

container.AddSingleton(() => washStepFactory);
container.AddSingleton<IWashProgramFactory>(() => new WashProgramFactory(ConfigMap.GetWashPrograms(washStepFactory)));
}

Invoking the builder

The CustomWashProgramBuilder is invoked through the ICustomWashProgramBuilder interface from Program.cs which is located in the SOLIDWashTunnel.Customers project.

var panel = container.GetService<IUserPanel>();
var builder = container.GetService<ICustomWashProgramBuilder>();

// Option 1: Adding specific wash steps.
IWashProgram customProgram = builder
.Add(WashStepType.ChasisAndWheelWashing)
.Add(WashStepType.Shampooing)
.Add(WashStepType.HighPressureWashing)
.Build();

// Option 2: Adding all available wash steps.
IWashProgram customProgram = builder
.AddAll()
.Build();

panel.SelectCustomizedProgram(customProgram)
.AsIndividual("Ledjon", "Behluli", Currency.USD)
.Start(Vehicle, PrintInvoice());

Analogy to the builder pattern

Lets connect the concepts defined in the UML diagram of the builder pattern, to the CustomWashProgramBuilder.

  • ICustomWashProgramBuilder interfaces is analogous to Builder. It represents the abstraction.
  • CustomWashProgramBuilder is analogous to ConcreteBuilder. It represents the concrete implementation of Builder.
  • Program.cs (or calling code) is analogous to Director.
  • IWashProgram generated upon calling Build is analogous to Product.

Principles


PrincipleAppliedExplanation
SRPCustomWashProgramBuilder has one responsibility, that is to build an IWashProgram.
OCPCustomWashProgramBuilder is closed for modification, but open for extension. Even if new wash steps are introduced (either built-in or external) the class does not have to change.
LSPThere is no inheritance involved, so LSP has no applicability.
ISPCustomWashProgramBuilder makes use of all the methods of ICustomWashProgramBuilder.
DIPThe high level module CustomWashProgramBuilder does not depend on the low level modules which are any derived types of IWashStep, WashStepFactory, and WashProgramFactory. It dependes on the abstractions IWashStep, IWashStepFactory, and IWashProgramFactory respectively.

Continue the series on Fluent Builder (part 3/3).

If you found this article helpful please give it a share in your favorite forums 😉.
The solution project is available at GitHub.