👋đŸģ Currently looking for work!
Please see my LinkedIn profile and get in touch, or see ways to support me in the interim.

â€Ļ has too many hobbies.

Functional Documentation

Without application of force to keep internal technical documentation up to date, that documentation will quickly become inaccurate.

Consider internal documentation about a codebase at your company. This commonly takes the form of text files checked into Git, a Notion or Confluence page, comments in the code, system design documents, or similar.

This documentation often exists, but every professional developer has had the experience of finding that the notes in the README about running the app are just plain wrong.

In this post, I want to give some examples discussing what I call functional documentation.

💡
Functional documentation: technical documentation that's both helpful and load-bearing, so it can't drift out of date.

I find that functional documentation is an invaluable and often under-used resource. If your documentation is load-bearing — meaning people notice if it breaks — then it'll get updated quickly in the normal course of dev work.

Functional documentation can come in any number of different flavors; here are a few. These examples have a few things in common:

  • They are broadly understood, easily discoverable, and can (typically) be used even by someone new to the codebase without additional training.
  • They're load-bearing: if they break, they get fixed.
  • They're informative: reading them tells you about the codebase; when they break, you can tell what's broken.

Tests

Tests are a fairly clear example of load bearing documentation. An automated unit or integration test suite might document:

  • invariants within the code
  • functional & product requirements
  • regressions that have been fixed

The key insight here is looking at tests not just as part of your build or deployment process, but looking at them as documentation. A unit test covering an invariant documents that that invariant is important; an integration test covering a functional requirement documents that that feature is important; a regression test documents that the code broke in some way in the past and it's important that it doesn't break again.

You can even use tests to cover invariants about the environment or other aspects of the business. A test might, for example, just verify that it can connect to some dependency and print a helpful message otherwise. I've even seen test suites that verify external resources (images or other assets) the code depends on is available, ensuring that important URLs remain online.

Remember, though, the tests must be load bearing! This means they have to run as part of your CI/CD process. You must require that bug fixes and new features come with tests that make viable functional documentation. You must have a culture that emphasizes fixing breakages, not ignoring them, and empowers developers to spend time on stuff like fixing flaky tests. Tools like GitHub merge policies can help, but at the end of the day cultural values are set and exemplified by your people.

Docker Compose files

Docker Compose files provide a valuable opportunity to document the full dependency stack for your application.

If your developers all use Docker Compose to run the application locally, it's impossible for the Docker Compose file to drift out of date. This is going to be much more valuable than a year-old page in a wiki that lists the dependencies the application used a year ago.

The mere existence of a Docker Compose file, critically, provides documentation about how to run the application.

Makefile

A Makefile with well-named tasks also provides documentation about how to run the app and perform various development tasks. Again, if this is something the team uses in the course of day to day development, it can't drift out of date: it's naturally going to be updated as things change.

A trick I like, which I adapted from this post, is to include a help Make target as the default:

default: help
.PHONY: help
help: ## Print help
	@grep -E '^[a-zA-Z_-/]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

This allows you to document Make tasks with comments beginning with ##; make help parses the Makefile and prints out those comments:

make help output from my open-source runner project

Well-written code

Finally, I argue that well-written code is functional documentation (it's certainly load-bearing!) and reduces the need for code comments. Consider the following pseudocode:

if strings.Contains(mailCfg.mailTo, "@") && mailCfg.smtpUser != "" && mailCfg.smtpPassword != "" && mailCfg.smtpHost != "" {
  sendMail(mailCfg)
}

The example above is difficult to read and parse. You can certainly tell what it does, but it takes a few seconds.

Consider this alternative, which is takes just a little more effort to write but is immensely more readable:

emailValid := strings.Contains(mailCfg.mailTo, "@")
smtpConfigValid := mailCfg.smtpUser != "" && mailCfg.smtpPassword != "" && mailCfg.smtpHost != ""

if emailValid && smtpConfigValid {
  sendMail(mailCfg)
}

This is a simple example, but it serves to demonstrate my point: I like to make my code read almost like written language whenever possible.

For AI Agents

These practices and artifacts are not just valuable for humans! AI programming tools like Claude Code or Devin work best when code is easy to understand, the build process is clear, and tests are simple to run.

It's certainly possible to use AI agents with a complex build process, or a clumsy test suite, but you'll get the most value out of Cloud Code with minimal effort if your codebase follows these best practices.

Conclusions

Other forms of internal technical documentation are definitely valuable, but often they're best understood as point-in-time historical artifacts rather than up-to-date reference material.

Rather than documenting how to run your app in a README that will need to be updated every time you onboard a new engineer, I recommend leaning heavily on well-written Makefiles, docker-compose files, and tests that both help explain the codebase and are required to develop it.

Without application of force to keep internal technical documentation up to date, that documentation will quickly become inaccurate. But that force can be applied in the natural course of software development; it doesn't have to be in the form of management or other external pressure.