From Unix Commands to APIs: Designing Software Around Contracts, Not Interfaces
There is a recurring problem in software engineering that is rarely discussed explicitly: we often design the program around its user interface.
A command-line application becomes a collection of commands. A web application becomes a collection of HTTP endpoints. A desktop application becomes a collection of screens and event handlers.
Then, sooner or later, someone wants an API, a CLI, a GUI, a Python library, or a browser extension.
The result is often duplication, adapters built on top of other adapters, and business logic scattered across presentation layers.
There is another way.
The approach I have been exploring can be summarized as:
Define the operation and its input/output contract first. Make the interface an adapter.
I would call this Contract-First, Interface-Agnostic Design.
It is not a completely new architecture. It is a practical combination of several established ideas: Unix philosophy, separation of concerns, hexagonal architecture (Ports and Adapters), structured data contracts, and file-based persistence.
But putting them together leads to a particularly useful design style for small Unix-oriented tools.
The Interface Should Not Define the Program
Consider a simple operation:
| |
The important question is not whether this operation is exposed through HTTP or a shell command.
The important question is:
| |
For example:
| |
Once this contract exists, many interfaces can implement it.
A command-line interface might expose:
| |
An HTTP API might expose:
| |
A Python client might expose:
| |
A graphical application might present:
| |
But underneath, they are all invoking the same operation.
The operation is the important abstraction.
The interface is not.
Unix Already Had This Idea
The Unix philosophy has always encouraged small programs that do one thing well and communicate through simple interfaces.
For example:
| |
Each program has a relatively simple contract:
| |
Standard input, standard output, standard error and exit codes provide a surprisingly powerful protocol.
The problem is that modern application development often abandons this simplicity.
Instead of:
| |
we get:
| |
Sometimes all of that is justified.
Sometimes it is simply accidental complexity.
Structured Output Changes Everything
A traditional Unix command might print:
| |
That is convenient for a human but difficult for another program to consume reliably.
Instead, the command can provide structured output:
| |
Now the same program becomes useful to both humans and machines.
For example:
| |
The CLI has effectively become a small API over standard output.
This is an important design principle:
Machine-readable output should be a first-class interface, not an afterthought.
For CLI programs, I particularly like the traditional Unix separation:
| |
That gives shell scripts a stable contract without sacrificing usability for humans.
The Core Should Know Nothing About HTTP
Suppose we implement:
| |
That function should not know whether the caller is:
- a browser,
- curl,
- a Python program,
- a desktop application,
- a shell script,
- or another server.
It should simply perform the operation and return a result.
Bad design:
| |
Now the business operation knows about both the console and HTTP.
A better design is:
| |
The HTTP layer converts that result into HTTP.
The CLI layer converts it into terminal output.
The Python library exposes it as a Python object.
The core remains independent.
This is essentially the Ports and Adapters idea applied at a very practical scale.
Files Can Be the Authority
This becomes particularly interesting when the persistent data is deliberately kept outside the service.
Imagine a secret store:
| |
The service does not own a database containing these secrets.
The files are the authority.
GPG provides encryption.
The filesystem provides persistence.
Git can provide versioning.
Syncthing can provide synchronization.
A service can provide an API.
A GUI can provide a graphical interface.
A browser extension can provide browser integration.
The architecture becomes:
| |
This is a very different philosophy from:
| |
The service becomes replaceable.
The data does not depend on the service.
A File Does Not Have to Contain Text
This principle also avoids a common mistake: assuming that everything in a secrets system is a string.
Credentials can be text.
But secrets can also be:
| |
There is no reason to turn every one of these into a JSON record.
A file-based service can preserve the original object:
| |
The encrypted file remains opaque.
When requested, the service decrypts it and returns the original bytes.
This leads to another useful distinction:
| |
The API does not need to understand every type of secret.
It only needs to understand the contract for storing and retrieving them.
The API Becomes an Adapter
Once the core operations are defined, an HTTP API becomes relatively boring.
And that is a good thing.
For example:
| |
The HTTP layer does four basic things:
| |
It should not contain the actual storage logic.
The CLI does essentially the same:
| |
The difference is only the transport.
This is why designing the contract before designing the interface is so powerful.
One Core, Many Interfaces
The resulting architecture is surprisingly simple:
| |
The interfaces are replaceable.
The core operations are stable.
The persistent data is independent again.
That gives us three particularly useful layers:
| |
For a Unix-oriented system, this is an extremely natural decomposition.
This Is Not Microservices
There is an important distinction here.
This architecture does not require dozens of services.
In fact, it often works best with one small program.
For example:
| |
could be a single Go binary providing:
| |
while the underlying storage remains:
| |
There is no need for:
| |
unless the actual problem requires them.
The objective is not distributed architecture.
The objective is clear boundaries.
A Useful Set of Rules
When designing this kind of application, I find the following rules useful.
1. Define operations before interfaces
Start with:
| |
not:
| |
2. Define input and output explicitly
Every operation should have a recognizable contract.
| |
3. Keep presentation out of the core
The core should not know about:
| |
4. Make machine-readable output first-class
Use structured output where appropriate.
JSON is useful, but not mandatory for binary data.
5. Use Unix conventions
For CLI applications:
| |
6. Keep persistent data independent
If the filesystem is the authority, the service should not secretly create another database containing the same information.
7. Make interfaces replaceable
You should be able to remove the GUI without losing the data.
You should be able to replace the HTTP server without migrating the secrets.
You should be able to access the files directly when necessary.
8. Treat errors as part of the contract
Do not make callers parse human error messages.
Prefer:
| |
with human-readable descriptions attached.
The Deeper Principle
The interesting part is not really REST.
It is not JSON.
It is not even Unix.
The deeper principle is this:
Separate what the program does from how somebody talks to it.
Once that separation exists, adding an interface becomes relatively cheap.
Want a CLI?
Add an adapter.
Want REST?
Add an adapter.
Want a Python library?
Add an adapter.
Want a GUI?
Add an adapter.
Want a browser extension?
Add an adapter.
The underlying operation remains the same.
From “Application” to “System”
This way of thinking also changes how I understand small Unix programs.
A program does not necessarily need to be a complete application.
It can be a capability.
For example:
| |
provides cryptographic capability.
| |
provides persistent storage.
| |
provides a password-management interface.
A new service can provide an API over the same underlying capability.
A GUI can provide another interface.
A browser extension can provide another.
The pieces remain useful independently.
This is very close to the original Unix idea of composing simple tools, but applied to modern APIs and software architecture.
Conclusion
I do not think the answer to modern software complexity is always another framework.
Sometimes the better approach is to go back to a simpler question:
| |
Then define that operation clearly:
| |
Everything else can become an interface.
For systems such as a file-based secrets manager, this produces an architecture that is both traditional and modern:
| |
The data remains portable.
The service remains replaceable.
The interfaces remain independent.
And the API is no longer something that has to be “added” to the application later.
The API is simply another way of talking to the same program.