Script vs Library vs Service for Reusable Logic Fully
Compare a script, a library and a service for reusable logic: coupling, language, deployment, latency, failure modes and a decision guide with examples.

You wrote the logic once: a function that validates an address, renders a report, or talks to a billing system. Now three other things need it. The tempting designs are three: leave it as a script that they call, extract it into a library they import, or stand it up as a service they call over the network. Each solves reuse and creates a different set of problems. This post compares them on the points that decide the choice. It extends script, library or binary and one big script or many small.
What each one is
- Script: an executable you run as a process. Callers pass arguments or stdin and read stdout and the exit status. The interface is the command line.
- Library: code imported into the caller's program (a Python module, a Go package, a sourced shell file). Calls are function calls, in the same process and the same language.
- Service: a long-running program that answers requests over a network, usually HTTP or gRPC. Callers can be in any language and on any machine.
Compare them
- Latency per call. Library: microseconds. Script: milliseconds for process start, more for an interpreter with imports. Service: a network round trip, plus serialization.
- Coupling. A library ties callers to its language and version; an upgrade means rebuilding every caller. A script and a service decouple callers from language, with a stable interface as the contract.
- Failure. A library fails inside the caller. A script fails as a non-zero exit, with all its output. A service can be down, slow or overloaded, so callers need timeouts, retries and a plan for when it is gone.
- State. Libraries and scripts hold no state between calls unless they write it somewhere. A service can hold caches, connections and queues, which is also a reason to have one.
- Operations. A script needs a host with its dependencies. A library needs nothing beyond a package build. A service needs deployment, monitoring, scaling, authentication and an on-call owner.
- Versioning. A service can change behind its interface, so all callers benefit at once, and one bug affects all of them at once. A library is versioned per caller.
An example: validating addresses
#!/usr/bin/env bash
# usage: validate-address ADDRESS exit 0 valid, 1 invalid, 2 usage
[[ ${1:-} =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]]As a script, it works from cron, from a deploy step and from another program via subprocess. A thousand addresses cost a thousand process starts, about a second, fine for a batch and wasteful in a web request. As a library function in the web application, the same check is free. As a service, it would be absurd for a regular expression. Now suppose validation needs a lookup in a large, frequently updated suppression list shared by four applications in three languages: that has state, many callers and one source of truth, and a service is justified.
A decision guide
- Start with a script if one or two callers run it occasionally from a shell or scheduler. It is the cheapest thing that works.
- Extract a library when callers are in the same language, call it often, and need low latency or the logic to run inside their process. Accept the coupling; use semantic versioning to manage it.
- Build a service only when you need shared state, access control, independent scaling or callers in several languages and the first two do not fit. Count the cost of operating it: someone has to run it.
- Do not skip steps in reverse: a service that wraps a function used by one program is complexity with no payoff.
Mixed designs
The shapes combine. A library can power both a script (a thin CLI wrapper) and a service (a thin HTTP handler), so the logic lives in one place and the interfaces are adapters. That is often the right end state: write the core as a library with tests, expose it as a command, and add a service only if demand appears. Go and Python make the pattern easy; see building a Go command line tool for the library-plus-command layout.
Where this does not apply
For logic embedded in shell scripts, the realistic choice is between a sourced file and a command, and the guidance is in splitting into modules. Regulatory or security needs (audit trails, secrets that callers must not hold) can force a service. Organizational lines matter too: a team that owns the logic and wants control over releases may choose a service for reasons that are not technical. Whatever the choice, document the interface.
Costs and benefits over a longer horizon
The three forms differ most in how they age. A script is cheap to write and cheap to change, and it has no build step, which makes it ideal for glue and one-off work. Over time it accumulates conditions, and without tests it becomes hard to modify safely, and its dependence on the installed tools makes behavior vary between machines. A library is code written to be used by other programs, with a documented interface that you promise to keep, and the promise is where the cost lies: every public function is something you must support, version and avoid breaking. It pays when several programs need the same logic and the interface is stable enough to commit to. A compiled binary packages code and its dependencies into one file that runs without a runtime, with types and a test suite, and pays for the extra effort with predictable behavior, speed and easy distribution, at the cost of a build and release process for every platform. The right choice depends on how long the code will live and how many people depend on it.
A useful question is who will run it and where. Code that runs on your machine is a script. Code that runs on many machines you do not control is probably a binary. Code that runs inside other programs is a library.
Shell libraries are not what they seem
People write shell libraries as files of functions that other scripts source. They work, and they lack what other languages give a library. There are no namespaces, so a function or variable in the library can collide with one in the caller, and the convention of a prefix on every name is a poor substitute. There is no versioning or dependency mechanism, so a caller breaks silently when the library changes, and finding the file at run time depends on a path. Variables set by the library leak into the caller. Options such as set -e and traps set by the sourced file change the caller's behavior. Return values are exit statuses or output captured with command substitution, which costs a process. These limits are manageable for a small private library used by a handful of scripts that live together in one repository and change together. They become painful when the library has many users, which is the point at which a different form, such as a small compiled tool with a command-line interface, gives a firmer boundary.
# mylib.sh: every name carries the prefix, since sourcing has no namespaces
mylib_log() { printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" >&2; }
mylib_require() { command -v "$1" >/dev/null 2>&1 || { mylib_log "missing: $1"; return 1; }; }A command-line tool is also a library in disguise: its arguments and output are the interface, usable from any language, which is a form of reuse that source-level libraries cannot offer.
Moving between the forms without a rewrite
Few projects pick the right form at the start, and moving is easier than people fear if you keep the interface stable. A common path starts as a script, grows a function that other scripts copy, becomes a sourced file, and then, when the logic needs real data structures or speed, becomes a small binary that the original script calls. The caller does not change, because the interface, a command with arguments and output, stays the same. That is the strangler pattern: replace one piece at a time behind a stable boundary, with tests that compare the old and new outputs for the same inputs, and delete the old code when the new one has run in production for a while. Do not rewrite everything at once. Keep the old implementation runnable in parallel during the move, and compare results on real input. The aim is to keep delivering while changing the foundation, with the form chosen at each step by evidence of what hurts.
See Martin Fowler's discussion of microservices and their trade-offs and POSIX utility conventions for command interfaces.


