Contents

Developer Experience in Open Source Software

Developer Experience in Open Source Software webp image

DevEx is becoming more and more important in organizations, where teams of developers optimize processes, practices, conventions, documentation, and basically any aspects of their work in order to make it more efficient and remove obstacles that break the flow. Here are some recommended reads on our tech blogs: Developer Experience Done Right part I by Michał Ostruszka, and part II by Marcin Baraniecki. In this article, I’d like to take a look at yet another interpretation of this term.

UX, where Developer is the User

For over a year, I have been working full-time on multiple Scala OSS libraries, most notably Tapir, sttp, and Ox, with some occasional contributions to other SoftwareMill projects like https://adopt-tapir.softwaremill.com/ or Magnolia. Many are widely adopted in the industry, making maintenance quite challenging. Together with other contributors, we constantly need to think about various aspects of the User Experience, where the user is usually a developer who tries to solve their problems with our great tools. There are also new contributors, often bringing ad-hoc fixes and updates. Their help is essential, so we really care about making it easy for newcomers to join, add code, test, etc. As it turns out, we keep asking ourselves: “what would be the developer’s experience?” often, where “experience” means different things. This post is a summary of these aspects. I believe they are important for long-living, well-adopted OSS projects and fresh ones that aspire to become widely used.

Naming things

Let’s start with the hard part. It’s also worth mentioning its evil twin - renaming things. Even with excellent release notes and migration guides (more on them later), you want to avoid frustrating the users with breaking changes. When naming public packages, classes, and functions, pay attention to:

Familiarity
It’s tempting to name your types using original words to make them distinct, but it’s often a bad idea. If you’re writing a library that does streaming, don’t hesitate to use terms like Source or Stream. Don’t try to reinvent well-known concepts only because fs2, zio-streams, or Akka use exactly these terms. If it’s a stream, it’s a stream. Use special terms only if you want to emphasize fundamental differences. Yes, it may result in clashes, which in Scala can be easily handled by renames in imports. The familiarity aspect may also play a role if other languages use certain names or symbols, and you feel it’s worth adapting them in your project.

Consistency
Spend some time studying the project’s naming conventions. Make sure that the names you choose for packages, types, and functions follow a logical scheme. For example:

  • Plurals, like where to use “WebSocket” and where to use “WebSockets”?
  • Errors and exceptions. Do they have “Error” or “Exception” in the type name? Are they verbs or nouns? It doesn’t feel right if one function returns ItemNotFound and another IncorrectElementError.
  • Abbreviations and shortening words like “Event” -> “evt”, “message” -> “msg” etc. Scala is much less baroque than Java, so it’s often not necessary to shorten words as frequently as possible to save space. Whatever you choose, stay consistent unless there’s a really good reason to break the pattern.
  • Symbolic operators - generally should be avoided, although the mentioned familiarity aspect may justify them in special situations.
    Here’s an example of a pretty deep discussion about naming a method in Ox, taking these and other factors into consideration: https://github.com/softwaremill/ox/issues/118.

Imports

Some Scala developers may remember the Cats library before it introduced import cats.syntax.all._. You either relied on import cats.implicits._, which significantly slowed down compilation, or needed to learn the naming conventions for individual imports depending on the implicits you looked for. It was quite a painful experience. IDEs suggest imports for types, but recalling proper functions from objects, implicits, or macros still may rely on the developer’s memory and intuition. The more a developer can rely on autosuggestions from memorable imports, the better.
Another important consideration is to avoid clashes. Let’s take a look at an example from the ‘sttp’ library, where I had to implement integration with another lib - Ox. Initially, I created a sttp.client4.ox package for this purpose. Since there’s also a top-level ox package, users could run into:

import sttp.client4._
import ox._ // resolved as sttp.client4.ox._

which leads to frustrating compilation errors about missing types while the developer is convinced that import ox._ means _root_.ox._. Structure your packages properly to avoid such clashes.

Developer-friendly errors

An exception message “Operation timed out after 30000ms (tried 3 times)“ is much more valuable than just “Operation timed out”. When developing errors, assume they will occur in production in the worst moment and that the information derived from the error type or message could be crucial. To ease the troubleshooting experience:
Avoid standard exceptions or wrap them in your lib-specific exceptions. Put as many details in the error as possible. Watch out for possible error messages containing large data or vulnerable/personal information. Entire requests, responses, and dumps of processed messages—always verify if they don’t go into exception messages or other textual fields, which may accidentally get logged.

Breaking changes

Be very explicit when releasing a new version of a tool or library with changes that require user adjustments.

  • Emphasize these changes in release notes.
  • Emphasize even more if changes affect runtime behavior and not compilation. Think of any risks that may be introduced if users switch to the new version without proper adjustments, and mention these risks.
  • Describe in detail how the users need to change their code.
  • Consider an additional migration guide document with all these instructions in one place (See this example from Tapir). It’s not rare for projects to have a long delay in updating dependencies. Whoever draws the unlucky ticket will be more than happy to start from such a guide.
    If changes are revolutionary, your project may need to become project2 - an entirely new line of releases. This new, reworked version means a lot of adjustments for users, which often results in postponing the update way too long. Some libraries work around this issue by changing the root package name, allowing it to depend on both old and new versions so that users can migrate gradually. For example, sttp.client2 vs sttp.client3 vs sttp.client4.

Public vs. internal

A typical convention teaches that only the user-facing types and functions should be public. A good additional rule is to keep internal code in an internal package so the codebase is easier to explore and understand. There’s also a radical approach where internal code lies in an internal package, but its visibility is still public. It may be a relief, especially for “libraries depending on libraries” where private visibility prevents extending functionalities. There are advanced developers who invent creative solutions requiring extensions or referencing non-public code. Do such cases justify making internal stuff public and introducing risks like excessive code completions or accidental/reckless usage of code not intended for the users? Personally, I’m not convinced, and I’d love to see some kind of a middle-way solution.

Documentation

We were promised a bright future where AI maintains all the documentation. This future was supposed to be just around the corner, but somehow, we don’t see it coming. Writing docs is still up to us, and it may be a deciding factor in how your project is received. Working on documentation may also help you to look at the project’s ergonomics from the user’s perspective. If you keep needing to add disclaimers about special cases, nuances, or caveats, treat it as a warning sign.

Time To Contribution

Documentation also means a good guide for potential contributors. If you want people to submit PRs, make sure it’s clear how to:

  • Navigate project structure and quickly start writing updates in the proper location.
  • Build the project or build partially where the changes are made.
  • Write and run tests, also partially. You don’t want a new contributor to repeatedly run many slow, irrelevant tests or wonder why they fail locally. They need to confidently run suites, single tests, or to run unit/integration tests for a single module.
  • Troubleshoot frequently occurring build/test issues.

Summary

Sometimes, it just feels good to work with a certain library, either as a user, or a contributor. Sometimes, it’s quite the contrary - the feeling is dreadful, but there’s no good replacement. A lot depends on how much the maintainers care about the user experience. Mentioned aspects are just some examples of important considerations we love about our favorite Scala projects. I hope they will help you with your project’s adoption and growing a community around it. Finally, as always, we are delighted to receive feedback and contributions in our SoftwareMill Scala libraries, so feel more than welcome!

Blog Comments powered by Disqus.