A case for using a Makefile in your project

Jan 16, 2024 · 5 min read

This post is going to present a case for using a Makefile in your project. This will be the top-level task runner for your project. You can use make to run all of your project tasks such as building, linting, testing, and deploying your project. It doesn’t matter what language your project is written in.

What is a Makefile?

Make gets its knowledge of how to build your program from a file called the makefile, which lists each of the non-source files and how to compute it from other files. When you write a program, you should write a makefile for it, so that it is possible to use Make to build and install the program.

Source

Straight out of the gate, this doesn’t necessarily seem like something we could make use of, does it? However, at its core, a Makefile allows you to define a bunch of instructions together. Each set of instructions can then be given a name, and this becomes the “target”. We use a tool called make to run one of the targets defined in a Makefile.

If you’re working on JavaScript projects, for example, you may be using tools like npm, yarn, or pnpm to run your tasks. A Makefile is a very similar concept, and I’d recommend you have both.

The reason for this recommendation is abstractions.

Abstraction

We can use make to define an abstraction layer between your internal project decisions, and the outside world (a contract). The outside world could include what gets run in your CI/CD pipeline, or what engineers run in their terminals to build and test the project. By defining and making use of a Makefile, you can change your internal project workings, but leave the contract intact.

A concrete example for a JavaScript project could be switching your project from using npm to pnpm. If you’re using tasks within your package.json you have probably exposed npm run ... tasks in your CI/CD pipeline, your README, and your engineers will be running those commands. If you change your tool to pnpm you suddenly need to change a lot more code to bed this in. Your engineers also need to change their muscle memory to use the new version of the command. However, if you had a Makefile as a facade for your project, your make build task would stay the same to the outside world, but the internals of what make build does would change to use pnpm.

Another example could be for a project written in Go, where there really isn’t a task runner, and you may need to remember a rather complicated command to build your Go binaries. By having a Makefile you can document all the key commands in one place, and reference them.

Example

Let’s take a look at a single make target from this repo, which is a Go project.

.PHONY: test
test: ## Run the unit tests
  go test ./... -coverprofile=coverage.out
  go tool cover -func=coverage.out

Above shows a single make target called test. We can execute this target by running make test.

❯ make test
go test ./... -coverprofile=coverage.out
ok      github.com/benmatselby/walter/cmd       0.504s  coverage: 18.2% of statements
ok      github.com/benmatselby/walter/cmd/board 0.243s  coverage: 75.7% of statements
ok      github.com/benmatselby/walter/cmd/search        0.844s  coverage: 90.6% of statements
ok      github.com/benmatselby/walter/cmd/sprint        1.052s  coverage: 85.5% of statements
go tool cover -func=coverage.out
github.com/benmatselby/walter/cmd/board/board.go:9:             NewBoardCommand100.0%
github.com/benmatselby/walter/cmd/board/issues.go:15:           NewIssueCommand33.3%
github.com/benmatselby/walter/cmd/board/issues.go:33:           ListIssues     93.8%
github.com/benmatselby/walter/cmd/board/list.go:13:             NewListCommand 33.3%
github.com/benmatselby/walter/cmd/board/list.go:30:             DisplayBoards  100.0%

As you can see, we have output from the two commands defined in the test target. This is the same command engineers use when testing the software on their machines, to what is run in the CI/CD platform for quality checks.

If we wanted to change the options to go test, we could do this in one place, and it not impact everywhere else. It would be a trivial encapsulated change. We could also switch out go test for another test runner, and it wouldn’t impact anything else either.

Automation

Taking this further, if you can define a standard Makefile for all of your projects with the same target names, you can probably automate more things at a higher level. For example, you could do the following:

Perhaps this could be part of an automated system to spin up your entire project stack on your local machine. It wouldn’t matter if some of those projects were Go, React, Python, PHP for example, as the public contract (The Makefile) is the same.

Summary

As with most things, you have to decide if adding an extra abstraction layer is worth it. I’ve been using a Makefile in my projects since about 2013 when we transitioned a PHP project away from ant/phing. These projects have ranged in the language used, but what is consistent is the name of the Make targets. I can pretty much clone any personal repo, and remember all the make commands irrelevant if the project is PHP, Python, JavaScript, Go etc.

As outlined in this post (2018) using make to define how to run and test your project has gone into a full ecosystem to build development environments for engineers.


See also