Shared library development essentials

Shared library development essentials

Many organizations, as they grow, reach a point where they need to introduce some common code. This code should be used across all engineering teams to handle common concerns like logging, tracing, security, and more. Typically, this is referred to as common libraries or shared packages.

Sometimes, there's a dedicated team for developing and maintaining this shared code, but more often, the code is collectively owned by all teams. This setup has its pros and cons. On the upside, any developer can propose changes and contribute. On the downside, without clear ownership and specific knowledge, things can get messy. Developing code used by many teams is a big responsibility. The code should be well-designed, easy to extend, have a clear versioning strategy, be well-tested, perform well, be secure, and be easy to install and use. This is where best practices and design principles like SOLID, KISS, and Separation of Concerns come into play.

Now, let’s talk about some dos and don'ts for developing a reliable and easy-to-use library.

DON'T over-engineer.

While it’s important to plan for future needs, over-engineering can lead to a complex codebase that’s hard to maintain and extend. Striking a balance between future-proofing and simplicity is key.

Suppose you're developing a logging library called MyCompany.Logging and you decide to use Serilog as your main logger. Instead of hiding Serilog behind a generic abstraction, embrace its functionality. Allow for Serilog configuration in the appsettings.json of a consumer as you would if Serilog was used directly. Customize the logging logic needed for all projects within your organization, but make the most out of the dependency.

Avoid getting caught in the trap of generic abstraction that claims to be easy to replace. If you've ever tried to replace a data layer that uses SQL Server with, say, MongoDB, you know what I mean. All abstractions are leaky.

there are no leakier abstractions than the ones that are built against a single implementation

DO name things explicitly

Continuing with the example above, name your logging library clearly - MyCompany.Logging.Serilog instead of MyCompany.Logging. When your library is installed and built, Serilog binaries will appear in the output folder. It's straightforward, so why hide it? If you switch to NLog later, create a new package MyCompany.Logging.NLog to take advantage of NLog’s strengths, instead of trying to retrofit it into the MyCompany.Logging package. You can retire the Serilog library once the NLog package is ready, or keep both to give more options to your developers.

DON'T impose dependency injection:

Avoid forcing dependency injection (DI) patterns within your library as it doesn’t own the composition root, and thus, shouldn’t dictate how dependencies are resolved. This could limit the ways in which your library can be used and may lead to conflicts with the DI container setup in consumer applications. Instead, you could provide a way to pass dependencies to your library in a straightforward manner, allowing the consumer to control how they are created and managed.

DO preserve the expected behavior

Keep your consumers happy by not changing the behavior drastically from one version to the next, unless it's a major upgrade. Keep the API backward-compatible. This is known as the principle of least surprise:

if a necessary feature has a high astonishment factor, it may be necessary to redesign the feature

For example with a logging library, the expected behavior would be to use a setup like this:

var builder = WebApplication.CreateBuilder(args);
builder.Host.UseLogging();

Here UseLogging() is an extension method in your library. When the application runs, the consumer expects to see the output in the console. If for some reason, a specific configuration in appsettings.json is needed to see the output or pre-requisite code, that’s a violation of the principle.

DON'T neglect versioning

Given that shared libraries will evolve over time, it's essential to have a robust versioning strategy in place. This helps in ensuring backward compatibility and smooth transitions to new versions of the library. SemVer is a good convention to use.

DO provide enough extension points

Allow consumers to pass a delegate and configure the library. For example, Serilog provides the following extension:

public static IHostBuilder UseSerilog(
       this IHostBuilder builder,
       Action<HostBuilderContext, IServiceProvider, LoggerConfiguration> configureLogger,
       bool preserveStaticLogger = false,
       bool writeToProviders = false)

When building a logging library on top of it, you’ll want to keep this. Create an abstraction like LoggerConfigBuilder to configure your library:

public class LoggerConfigBuilder
{
    internal Action<IServiceProvider, LoggerConfiguration>? ServiceProviderConfig { get; private set; }
    internal Action<HostBuilderContext, LoggerConfiguration>? ContextConfig { get; private set; }

    internal LoggerConfigBuilder WithServiceProviderConfig(Action<IServiceProvider, LoggerConfiguration> config)
    {
        ServiceProviderConfig = config;
        return this;
    }

    internal LoggerConfigBuilder WithContextConfig(Action<HostBuilderContext, LoggerConfiguration> config)
    {
        ContextConfig = config;
        return this;
    }
}

and a couple of helpers:

public static class LoggerConfigBuilderExtensions
{
    public static LoggerConfigBuilder WithContext(this LoggerConfigBuilder builder, Action<HostBuilderContext, LoggerConfiguration> config) => builder.WithContextConfig(config);
    public static LoggerConfigBuilder WithServiceProvider(this LoggerConfigBuilder builder, Action<IServiceProvider, LoggerConfiguration> config) => builder.WithServiceProviderConfig(config);
}

Extend UseLogging() entry point:

public static IHostBuilder UseLogging(
       this IHostBuilder builder,
       LoggerConfigBuilder configBuilder)

Now you can use it like this:

var builder = WebApplication.CreateBuilder(args);
var configBuilder = new LoggerConfigBuilder().WithContext((_, logger) =>
{
    // Any additional configuration can go here
});

builder.Host.UseLogging(configBuilder);

DON'T require consumers to depend on things they probably don't need

It's easy to start adding more dependencies than needed in your library. Keep it to the minimum to avoid a memory and performance hit. Consider a modular setup with additional packages for extending functionality (e.g. MyCompany.Logger.AdditionalFeature).

DO learn from open-source

There are loads of great open-source libraries and frameworks on GitHub. Examples include AutoMapper, Serilog, and Dapper. They can provide valuable lessons in crafting effective shared libraries.

DON'T forget about testing and documentation

Comprehensive testing, including unit testing, integration testing, and performance testing, is crucial to ensure the reliability and stability of shared libraries. Ensure that your library is well-documented. Provide clear explanations and examples of how to use the library, highlighting the benefits and any potential pitfalls.