GDPR Cookie Consent by Free Privacy Policy Reusable Build Components with Interface Default Implementations - Matthias Koch

Reusable Build Components with Interface Default Implementations

 

Photo by Louis Reed on Unsplash

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:

Every time a new version of C# comes out, I quickly find ways to adopt the newly introduced features into my code. C# 8 is an exception. Question: have you used default interface members in your code yet?

In the context of build automation and specifically NUKE, they have been on my radar for some time already. After all, they 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:

Build Graph

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!

  1. I’ve also written about C# 8 language features in action on our JetBrains .NET Blog