TL;DR: Default implementations in interfaces are a powerful approach to extract common build infrastructure into reusable components. They allow to overcome the diamond problem that we traditionally have when just using a hierarchy of base classes. By providing a rich target definition model, NUKE ensures that predefined build steps can easily be integrated with custom build steps, or extended without losing any of the existing information.
C# 8 introduced a lot of new language features that can help1 us to make our codebase more readable, increase performance, or lower the memory footprint. However, one of these, namely default implementations in interfaces, seems to be more of a niche feature:
In the context of build automation and specifically NUKE, they have been on my radar for some time already. After all, the available time made it hard, but thanks to Thomas Unger this dream has finally come true! 👏
Writing Build Components
Utilizing different strategies for build sharing can greatly reduce the maintenance effort for our build infrastructure when different projects/repositories are building the same way. So they can help developers working in teams, but could also be used in the OSS community. With interface default implementations, we’re extending the already existing strategy of using NuGet packages to share common build logic. But how exactly will this look like? Let’s dive right into writing some build components:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
interface IHasSolution
{
[Solution]
Solution Solution => GetInjectionValue(() => Solution);
}
interface IBuild : IHasSolution
{
[Parameter]
Configuration Configuration => GetInjectionValue(() => Configuration);
Target Restore => _ => _
.Executes(() =>
{
DotNetRestore(_ => _
.SetProjectFile(Solution)
});
Target Compile => _ => _
.DependsOn(Restore)
.Executes(() =>
{
DotNetBuild(_ => _
.SetProjectFile(Solution)
.EnableNoRestore()
.SetConfiguration(Configuration)
});
}
With the IHasSolution
interface, we are defining that our build has a Solution
property that provides access to our solution model. The IBuild
interface extends on that information and introduces two targets Restore
and Compile
that actually build the solution with a given configuration that can be passed as parameter. In our build class, we only need to inherit those interfaces:
1
2
3
4
class Build : NukeBuild, IBuild
{
public static void Main() => Execute<Build>();
}
Moving forward, we could also define one more interface IPublishNuGet
that takes care of packing and pushing NuGet packages:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
interface IPublishNuGet : IBuild
{
AbsolutePath PackageDirectory { get; }
Target Pack => _ => _
.DependsOn(Compile)
.Executes(() =>
{
DotNetPack(_ => _
.SetProject(Solution)
.EnableNoBuild()
.SetConfiguration(Configuration)
.SetOutputDirectory(PackageDirectory)
.SetVersion(GitVersion.NuGetVersionV2)
});
[Parameter]
string Source => GetInjectionValue(() => Source) ?? "https://api.nuget.org/v3/index.json";
[Parameter]
string ApiKey => GetInjectionValue(() => ApiKey);
Target Publish => _ => _
.DependsOn(Pack)
.Executes(() =>
{
var packages = PackageDirectory.GlobFiles("*.nupkg");
DotNetNuGetPush(_ => _
.SetSource(Source)
.SetApiKey(ApiKey)
.CombineWith(packages, (_, v) => _
.SetTargetPath(v)),
degreeOfParallelism: 5,
completeOnFailure: true);
});
}
Again, we are extending our process on an existing interface: the Pack
target from our new IPublishNuGet
interface depends on the Compile
target defined in the IBuild
interface. Also note, how the interface now requires to actually implement a property PackageDirectory
, which is used by the default implementation:
1
2
3
4
5
6
class Build : NukeBuild, IBuild, IPublishNuGet
{
public static void Main() => Execute<Build>();
string PackageDirectory => RootDirectory / "output" / "packages";
}
But what if we want to extend the build pipeline in between? For instance, to validate packages, clean our repository, or to announce a new release? With NUKE, this is rather easy using its target definition model:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Build : NukeBuild, IBuild, IPublishNuGet
{
public static void Main() => Execute<Build>();
Target Clean => _ => _
.Before<IBuild>(x => x.Restore)
.DependentFor<IPublishNuGet>(x => x.Publish)
.Executes(() => { /* */ });
Target ValidatePackages => _ => _
.DependsOn<IPublishNuGet>(x => x.Pack)
.DependentFor<IPublishNuGet>(x => x.Publish)
.Executes(() => { /* */ });
Target Announce => _ => _
.TriggeredBy<IPublishNuGet>(x => x.Publish)
.Executes(() => { /* */ });
}
We’ve added a Clean
target that will be executed as a dependency for Publish
, but also before the Restore
target. Another target ValidatePackages
is executed right before Publish
, while Announce
is triggered by Publish
and will execute right after.
Provoking Diamond Problems
So far, our build implementation doesn’t really justify the demand for interfaces yet. We could have also used a hierarchy of base classes along the way. However, for our next step, we’ll actually need the flexibility of multiple inheritance. Suppose we want to extract the Announce
target in its own interface and also make it more general purpose, so that it can be used when publishing a website or a mobile application. Think of it as the single-responsibility principle for build components. Let’s introduce the IAnnounce
interface:
1
2
3
4
5
6
7
interface IAnnounce
{
Target Announce => _ => _
.Executes(() => { /* */ });
string Message { get; }
}
Firstly, we’ve introduced a Message
property, so that every individual build class can specify, what message should be sent. Secondly, we’ve removed IPublishNuGet
as a base interface and Publish
as a trigger dependency. This information goes into the build class as well:
1
2
3
4
5
6
7
8
9
10
class Build : NukeBuild, IBuild, IPublishNuGet, IAnnounce
{
public static void Main() => Execute<Build>();
Target IAnnounce.Announce => _ => _
.Inherit<IAnnounce>(x => x.Announce)
.TriggeredBy(Publish);
string IAnnounce.Message => "New Release Out Now!";
}
Here, we’re redefining the Announce
target and calling Inherit<T>
to let it derive from the default target definition in the IAnnounce
interface. The target will keep its original actions, but will be extended with a trigger from Publish
. For better understanding, we can always create a visual representation of our dependency graph using nuke --plan
:
A similar trick as with default implementations and Inherit
, we can use in a hierarchy of build classes that defines virtual
targets:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class BaseBuild : NukeBuild
{
public virtual Target ComplexTarget => _ => _
.Executes(() => { /* complex logic */ };
}
class Build : BaseBuild
{
public static void Main() => Execute<Build>();
public override Target ComplexTarget => _ => _
.Base()
.Requires(() => AdditionalParameter)
.Executes(() => { /* additional logic */ };
}
Here, we’re actually overriding an existing target, and call Base
to execute the original target definition. This essentially mimics a base.<Method>
call in the realm of target definitions.
Conclusion
Default implementations in interfaces will probably not make it to every codebase out there. For the purpose of defining reusable build components and integrating them into specific build pipelines though, they’re the perfect fit. We’re no longer coupled to use a strict hierarchy of build targets, but instead we can compose our build from multiple independent targets and connect them as needed. Most importantly, we reduce the maintenance cost when different projects need to be built the same way. With NUKE, we will continue to add more build components following this approach.
Compose your builds flexibly with NUKE!
-
I’ve also written about C# 8 language features in action on our JetBrains .NET Blog ↩