I remember being quite apprehensive about open sourcing my first component from work on Chime. I guess I was a little worried about how it would be received. Of course, it turned out just fine. In fact, it went well enough that I have continued open sourcing more and more components. Across my personal and Chime projects, I now maintain 42 packages.

One reason there are so many is I’ve come to be a huge believer in small, focused libraries. And while I do love this approach, it does come with a drawback: transitive dependencies.

The Problem

At first, I was pretty liberal with my own packages’ dependencies. Yet consistently, the number one bit of feedback I get about people hesitating to use things I make is not bugs, lack of features/documentation. It’s wanting to avoid dependencies. People just don’t like dependencies.

And you know what they like even less? Dependencies that have dependencies. This situation is called transitive dependencies, and I now think about it a fair bit when working on my own projects.

That’s because it is a particularly tricky problem for small libraries. They often need to be used in combination with other libraries to solve concrete problems.

Case Study: EditorConfig

To set the stage, we’re going to look at a package I published relatively recently for working with editorconfig files. Editorconfig is a way to help text editors maintain a standard coding style across users.

(Some readers may wonder why I did not just wrap the editorconfig C library up in a Swift package. The build process for that C library is non-trivial, and SPM cannot handle it without considerable modification.)

Editorconfig makes use of patterns called “globs”. Interpreting these patterns is well supported by the fnmatch standard C API. However, the editorconfig standard also supports something called “grouping”, which actually is not part of the standard glob syntax. But, it is really handy. In fact, it is so handy that the Language Server Protocol also uses globs with grouping in a number of places. And as it turns out, I also maintain another library for interacting with language servers.

Group expansion is something typically done by a shell, and it is difficult to do without one. So, I now have two, unrelated places where I need to handle the same complex operation.

A Protocol?

One great technique for breaking transitive dependencies is to introduce a protocol. We could make an API like this:

protocol GlobPatternMatcher {
    func match(string: String) -> Bool
}

class Resolver {
    init(globPatternMatcher: GlobPatternMatcher) {
        ...
    }
}

The Resolver class has a dependency on some type that can match glob patterns. So we pull that out into a protocol, and let our clients define how it should work.

A Function?

In this case, we don’t really need any of the extra powers that a protocol provides. We can get by with just a single function!

class Resolver {
    typealias GlobPatternMatcher = (String) -> Bool

    init(globPatternMatcher: @escaping GlobPatternMatcher) {
        ...
    }
}

This does everything we need and is also wonderfully simple.

A Plain Dependency?

Whether we choose to use a function or protocol, we still have an issue. Users of this package need to find a way to evaluate the glob patterns. This could possibly be done via another package. Or, they might opt to just use fnmatch and handle grouping incorrectly.

The point is, we’ve shifted the responsibility onto every client that would ever use this package. In the case of editorconfig, it would be easy to do, but hard to justify. This behavior is at the core of what this library is supposed to do.

So, I oped to just make another package that handles the glob logic and add it as a dependency.

Recommendations

I now try hard to think about any transitive dependency I bring in to my own packages. If I can pull out some non-critical functionality, I will. And that’s important. The package must be useful without whatever dependency you are trying to remove. If clients will always need it, you’ve saved nothing and also made your package harder to use.

When I do this, I typically reach for functions first. If I cannot get by with just one, I’d try a struct that contains a bunch of functions. Only if those two don’t work will I turn to protocols. This is plain old dependency injection. But, I haven’t run into it too often to minimize cross-package dependencies, and I think its a great way to do it.

If you are a package author, I’d recommend thinking about your own transitive dependencies more deeply. Maybe a single function could help.