Build Components
With build components you can implement your build infrastructure once, and compose individual builds across different repositories. Central to the idea of build components are interface default implementations, which allow you separating targets by their concerns following the single-responsibility principle, and pulling them into your build just by inheriting the interface. A typical build based on components could look like this:
The component stubs from above can be translated into code as follows, whereas the INukeBuild
base interface allows the components to use build base properties:
interface ICompile : INukeBuild
{
Target Compile => _ => _
.Executes(() => { /* Implementation */ });
}
interface IPack : INukeBuild
{
Target Pack => _ => _
.Executes(() => { /* Implementation */ });
}
interface ITest : INukeBuild
{
Target Test => _ => _
.Executes(() => { /* Implementation */ });
}
In the actual Build
class, all you have to do is to inherit the components:
class Build : NukeBuild, ICompile, IPack, ITest
{
// Targets are inherited
}
Parameters​
In build components, you can use parameters and other auto-injection attributes, like GitRepositoryAttribute
or SolutionAttribute
, similar as in regular build classes. Though, since interfaces can't define instance fields or properties, the INukeBuild
base interface provides a helper method that caches and returns resolved values for you:
interface IComponent : INukeBuild
{
[Parameter]
string Parameter => TryGetValue(() => Parameter);
[Solution]
string Solution => TryGetValue(() => Solution);
}
The TryGetValue
method can return null
, for instance, when a parameter is not available. If you want to provide a default value, you can use the null-coalescing operator:
interface IComponent : INukeBuild
{
[Parameter]
string Parameter => TryGetValue(() => Parameter) ?? "default";
}
Note that the fallback value is created on every property access, so you might want to cache it in a static field.
Parameter Prefixes​
For better distinction of similarly named component parameters and to avoid smurf naming techniques, you can use the ParameterPrefixAttribute
to introduce a common prefix for all parameters in a component:
[ParameterPrefix(nameof(IComponent1))]
interface IComponent1 : INukeBuild
{
// Resolved as IComponent1Value
[Parameter] string Value => TryGetValue(() => Value);
}
[ParameterPrefix(nameof(IComponent2))]
interface IComponent2 : INukeBuild
{
// Resolved as IComponent2Value
[Parameter] string Value => TryGetValue(() => Value);
}
Dependencies​
You can define dependencies between targets similar as in regular build classes. Since targets from components cannot easily be referenced from their inheritors1, you must pass the component type as a generic parameter and provide the target through a lambda expression:
class Build : NukeBuild, IComponent
{
Target MyTarget => _ => _
.DependsOn<IComponent>(x => x.Target)
.Executes(() =>
{
});
}
When a build component only defines a single target, you can use the shorthand syntax and omit the lambda that specifies the target. For instance, the above example can become:
class Build : NukeBuild, IComponent
{
Target MyTarget => _ => _
.DependsOn<IComponent>()
.Executes(() =>
{
});
}
Loose Dependencies​
Apart from regular dependencies, you can also define loose dependencies that only get applied when the respective component is also inherited. This allows you to compose your build more flexibly without imposing a particular inheritance chain:
- Execution Dependencies
- Ordering Dependencies
- Trigger Dependencies
interface IComponent1 : INukeBuild
{
Target A => _ => _
.TryDependentFor<IComponent2>() // Choose this...
.Executes(() => { });
}
interface IComponent2 : INukeBuild
{
Target B => _ => _
.TryDependsOn<IComponent1>() // ...or this!
.Executes(() => { });
}
interface IComponent1 : INukeBuild
{
Target A => _ => _
.TryBefore<IComponent2>() // Choose this...
.Executes(() => { });
}
interface IComponent2 : INukeBuild
{
Target B => _ => _
.TryAfter<IComponent1>() // ...or this!
.Executes(() => { });
}
interface IComponent1 : INukeBuild
{
Target A => _ => _
.TryTriggers<IComponent2>() // Choose this...
.Executes(() => { });
}
interface IComponent2 : INukeBuild
{
Target B => _ => _
.TryTriggeredBy<IComponent1>() // ...or this!
.Executes(() => { });
}
Extensions and Overrides​
Another SOLID design principle that can be applied to build components is the open-closed principle. Once you have pulled a target into your build, it can be extended or overridden using explicit interface implementations:
- Extending Targets
- Overriding Targets
class Build : NukeBuild, IComponent
{
Target IComponent.Target => _ => _
.Inherit<IComponent>()
.Executes(() => { });
}
class Build : NukeBuild, IComponent
{
Target IComponent.Target => _ => _
.Executes(() => { });
}
With build components you can push the separation of concerns as far as you wish. For instance, consider the following example where a common ICompile
component only defines the dependency to the IRestore
component. Another two derived types of ICompile
provide the actual implementation of the target using the .NET CLI and MSBuild:
interface IRestore : INukeBuild
{
Target Restore => _ => _
.Executes(() => { /* Implementation */ });
}
interface ICompile : INukeBuild
{
Target Compile => _ => _
.TryDependsOn<IRestore>();
}
interface ICompileWithDotNet : ICompile
{
Target ICompile.Compile => _ => _
.Inherit<ICompile>()
.Executes(() => { /* .NET CLI implementation */ });
}
interface ICompileWithMSBuild : ICompile
{
Target ICompile.Compile => _ => _
.Inherit<ICompile>()
.Executes(() => { /* MSBuild implementation */ });
}
Targets that follow later in the execution plan can now reference the implementation-agnostic definition:
class Build : NukeBuild, ICompileWithDotNet
{
Target Pack => _ => _
.DependsOn<ICompile>()
.Executes(() => { /* Implementation */ });
}
Footnotes​
-
Interface default members behave like explicit interface implementations, which means that to access their members, the
this
reference must be cast explicitly to the interface type. For instance,((IComponent)this).Target
. ↩