What the build vs buy decision actually covers: three cost phases

The build vs buy question is usually framed as "how long would it take us to write this?" That framing misleads because the initial build is the cheapest and most visible phase. A team can estimate a few sprints to stand up an internal job queue. It rarely estimates the years of upgrades, incident response and onboarding that follow. Buying moves some of those costs to a vendor, but not all of them. The decision is really about which party carries each cost phase, and for how long.

Split the costs into three phases:

  • Initial build is visible. It lives in sprint plans and estimates.
  • Integration and migration is partly visible. Teams plan the adapters and data moves, but the long tail of edge cases usually surfaces after launch.
  • Steady-state operation is underestimated. It is recurring, spread across many people, and rarely tracked against the original decision.

A reusable design-doc template for a candidate internal job queue looks like this:

Line item Phase Build: who carries it Buy: who carries it
Core scheduling, retries, visibility timeouts Initial build Your team Vendor
Client libraries, producer/consumer migration Integration Your team Your team
Runtime and dependency upgrades Steady state Your team Mostly vendor
Security patches Steady state Your team Vendor, but you track advisories
On-call for queue outages Steady state Your team Shared: vendor fixes, you triage
Documentation and onboarding Steady state Your team Your team, for your usage patterns

Fill in every row before comparing options. If a row has no owner, that cost lands on your team by default.

candidate: internal-job-queue
options: [build, buy]

line_items:
  - item: Core scheduling, retries, visibility timeouts
    phase: initial_build
    owner: { build: platform-team, buy: vendor }
    # Illustrative estimate with assumed inputs: 0.5 engineer-years to build
    # (matching the TCO example below); buying assumed to need only setup work.
    estimate_person_weeks: { build: 26, buy: 2 }

  - item: Client libraries and producer/consumer migration
    phase: integration
    owner: { build: platform-team, buy: platform-team }
    # Illustrative estimate with assumed inputs: buying can take more adapter
    # work because clients must fit the vendor's API rather than your own.
    estimate_person_weeks: { build: 6, buy: 8 }
    notes: Expect edge cases to surface after launch; reserve time for them.

  - item: Runtime and dependency upgrades
    phase: steady_state
    owner: { build: platform-team, buy: vendor }
    recurring: per_quarter

  - item: Security patches and advisory tracking
    phase: steady_state
    owner: { build: platform-team, buy: vendor (patch) + platform-team (track) }
    recurring: continuous

  - item: On-call for queue outages
    phase: steady_state
    owner: { build: platform-team, buy: vendor (fix) + platform-team (triage) }
    recurring: continuous

  - item: Documentation and onboarding for our usage patterns
    phase: steady_state
    owner: { build: platform-team, buy: platform-team }
    recurring: per_new_hire

A YAML design-doc template that lists each cost line item for an internal job queue by phase, with an explicit owner for both the build and buy options so that unowned costs stand out before the comparison.

Differentiation as the first filter: who should build and what to buy

Before comparing costs, ask whether the component is part of what makes your product different. If a customer would notice the difference between your version and a competitor's, the component is a build candidate. If a customer would never know which vendor sits underneath, it is plumbing.

Internal tools that encode your own business workflows usually pass this test. Examples include a pricing engine shaped by your contracts, a fulfillment scheduler that knows your warehouse constraints, and an approval flow that mirrors how your compliance team actually works. No vendor has your rules. Buying something generic often means bending your process to fit the product or keeping a pile of configuration that turns into its own custom code.

Message queues, authentication, and log storage usually fail the test. Thousands of teams have the same requirements, so managed services already handle the edge cases you haven't hit yet: token rotation, retention policies, partition rebalancing. Building these spends your best engineers on problems with no payoff for your product.

Both sides of the decision carry predictable biases. Engineers lean toward building because the work is interesting and looks good on a resume. Procurement and leadership lean toward buying because a fixed contract looks predictable, even when integration and usage-based pricing make it less predictable than it appears. Neither bias is a reason to dismiss the other side. Write both down in the decision record so reviewers can see which way the room was leaning when the call was made.

Impact on cost and engineering time: an illustrative TCO comparison

Write the costs down as numbers, and the lean in the room becomes something reviewers can argue with. The model below compares cumulative cost over N years:

  • Build(N) = initial build effort × L + N × (m × L + infra)
  • Buy(N) = Σ over years [(seats × seat price + requests × request rate + egress GB × egress rate) × (1 + g)^(year−1) + c × L]

Here L is loaded cost per engineer-year, m is the maintenance FTE fraction, g is annual usage growth, and c is the FTE fraction spent integrating the managed service and watching its bill.

Illustrative estimate (assumed numbers, not a benchmark):

  • L = $200k
  • Build path: 0.5 engineer-years to build, m = 0.3, and $20k/yr of infrastructure
  • Buy path: a $60k first-year bill, g = 40%, and c = 0.05
Year Build (cumulative) Buy (cumulative)
1 $180k $70k
2 $260k $164k
3 $340k $292k
4 $420k $466k

Under these assumptions, buying is cheaper until year 4. The crossover moves with the inputs:

  • Maintenance fraction. At m = 0.15 it moves earlier, to year 3.
  • Usage growth. At g = 20% it moves past year 5.

Per-seat pricing also scales with headcount, so a growing team pulls the crossover earlier. The model is most sensitive to two inputs: m and g. Teams usually underestimate m and guess g. Run both a low and a high value for each before trusting a single crossover year.

The spreadsheet leaves out one factor. The 0.3 FTE spent maintaining a commodity system is engineering time that is not going into your product. Buying also adds work of its own: someone has to track egress and per-request charges, and that task is c in the formula.

L = 200_000            # loaded cost per engineer-year
BUILD_EY = 0.5         # initial build effort, engineer-years
INFRA = 20_000         # build path: yearly infrastructure spend
C = 0.05               # buy path: FTE fraction for integration + bill watching

SEATS, SEAT_PRICE = 20, 1_200          # $/seat/yr
REQ_K, REQ_RATE = 400_000, 0.05        # thousands of requests/yr, $ per 1k
EGRESS_GB, EGRESS_RATE = 80_000, 0.20  # GB/yr, $ per GB
BILL_Y1 = SEATS * SEAT_PRICE + REQ_K * REQ_RATE + EGRESS_GB * EGRESS_RATE  # = 60k

def build(n, m):
    return BUILD_EY * L + n * (m * L + INFRA)

def buy(n, g):
    # Bill compounds with usage growth g; integration cost c*L stays flat.
    return sum(BILL_Y1 * (1 + g) ** (y - 1) + C * L for y in range(1, n + 1))

def crossover(m, g, horizon=8):
    # First year in which cumulative Buy exceeds cumulative Build.
    for n in range(1, horizon + 1):
        if buy(n, g) > build(n, m):
            return n
    return None  # no crossover within horizon

for n in range(1, 5):
    print(f"year {n}: build ${build(n, 0.3)/1e3:,.0f}k  buy ${buy(n, 0.4)/1e3:,.0f}k")

for m in (0.15, 0.3, 0.45):
    for g in (0.2, 0.4, 0.6):
        x = crossover(m, g)
        print(f"m={m:.2f} g={g:.0%}: crossover {'year ' + str(x) if x else '> 8 years'}")

A rerunnable Python version of the Build(N) and Buy(N) formulas that reproduces the illustrative table and sweeps maintenance fraction and usage growth to show the crossover as a range.

Operational burden is a staffing question: when to avoid building

A system you build is a system you run. Before writing the first line, ask who will own it in eighteen months. Ownership means a named team, not a named person. It means an on-call rotation that includes the service, runbooks that someone other than the author can follow at 3 a.m., and dashboards and alerts that match how the system actually fails. If those things are not funded, you are not building infrastructure. You are building a liability with a delayed start date.

The common failure is quiet. A team builds an internal deploy tool or job scheduler as a project, ships it, and moves on. It keeps working, so it gets deprioritized. Dependencies age, the one engineer who understood the retry logic changes teams, and the documentation describes the first version. Nobody notices this debt until an incident. By then the bus factor is zero, and the fix starts with archaeology.

Buying does not remove that risk. It moves it to the vendor. You inherit their outages, pricing changes, API deprecations and roadmap priorities. During an incident, your options shrink to checking a status page, opening a ticket and communicating with your own users. That trade is often correct, because the vendor staffs on-call for their system and you do not have to. But it is still a trade.

The test is simple. If you cannot name the owning team and fund its on-call load, building is the wrong answer. Buy the capability, or defer it until you can staff it.

Alternatives between build and buy: self-hosted open source, hosted open source, and thin wrappers

Between those two poles sit three middle options. Each moves the maintenance cost to a different place.

Option Who runs it Portability Main hidden cost
Self-hosted open source Your team High Upgrades, patching, backups, on-call
Hosted open source Provider High, same protocol Price premium, provider limits
Managed service behind a thin interface Vendor Medium, via adapters Keeping the interface narrow

Self-hosting open source has no licence fee, but you carry every maintenance cost a build would carry except writing the code. Hosted open source keeps the wire protocol and data formats, so you can leave later without rewriting clients. The provider handles the operations work.

The third option wraps a managed service in an internal interface, for example a BlobStore with put, get and delete. All vendor-specific calls live in adapters:

Architecture: application, internal interface, vendor adapters

  • Application code
    • → BlobStore interface
      • → Vendor A adapter → managed service A
      • → Vendor B adapter → managed service B
      • → In-memory adapter → used in tests

The wrapper fails when it grows. Teams add lifecycle rules, signed URLs and vendor-specific consistency options until the interface becomes a lowest-common-denominator product with its own backlog. At that point it blocks the vendor's best features, leaks vendor semantics anyway, and costs more to maintain than a one-time migration would have. Keep it to the operations you actually call. Let teams use the vendor directly when they need more.

// The internal interface: only the operations the application actually calls.
// Resist adding lifecycle rules, signed URLs or consistency knobs here.
export interface BlobStore {
  put(key: string, body: Uint8Array): Promise<void>;
  get(key: string): Promise<Uint8Array | null>; // null when the key does not exist
  delete(key: string): Promise<void>;
}

// Test adapter: same contract, no network, no vendor SDK.
export class InMemoryBlobStore implements BlobStore {
  private items = new Map<string, Uint8Array>();

  async put(key: string, body: Uint8Array): Promise<void> {
    this.items.set(key, body);
  }

  async get(key: string): Promise<Uint8Array | null> {
    return this.items.get(key) ?? null;
  }

  async delete(key: string): Promise<void> {
    this.items.delete(key);
  }
}

// A vendor adapter (for example, a VendorABlobStore class) implements the same
// three methods and keeps every vendor-specific call (SDK client, error codes,
// retries) inside its own file. Nothing outside that file imports the SDK.

// Application code depends on the interface, never on a vendor SDK.
export async function saveReport(store: BlobStore, id: string, pdf: Uint8Array) {
  await store.put(`reports/${id}.pdf`, pdf);
}

// Wiring happens in one place, so swapping vendors touches one line.
const store: BlobStore = new InMemoryBlobStore();
void saveReport(store, "2026-q3", new Uint8Array([0x25, 0x50, 0x44, 0x46]));

A minimal TypeScript BlobStore interface with an in-memory test adapter, showing how application code depends only on three operations while vendor-specific calls stay inside adapters.

When building or self-hosting is justified: hard constraints and exit cost

Some constraints force the decision rather than inform it:

  • Data residency or compliance. If a regulation or contract requires data to stay in a specific region or under your direct control, and no vendor can meet that, the question is settled.
  • Latency or network locality. A hot path that cannot tolerate a round trip outside your network needs the component running beside the workload.
  • Scale. Per-unit pricing that is reasonable at low volume can grow faster than the cost of staffing the system yourself.
  • Functionality gaps. A missing capability that blocks a core use case. A nice-to-have does not count.

"We already know how to run it" and "we'd rather own it" are not on this list. Familiarity is a real factor, but it belongs in the cost comparison. It is not a constraint that overrides that comparison.

Before you adopt a managed service, price the exit as well as the entry:

Exit factor Low exit cost High exit cost
Data export Bulk export in open, documented formats Partial exports or proprietary formats only
API portability Standard protocol or wire-compatible API Vendor-specific SDK spread across the code
Proprietary features Used sparingly and isolated Core logic depends on them
Migration path Dual-write or replication is possible Cutover requires downtime or a rewrite

Lock-in is acceptable when it is deliberate: you chose a proprietary feature because its value exceeds the expected exit cost, and you recorded that reasoning. The lock-in that causes damage is the kind nobody notices until a migration begins. Migrations in either direction are slow. Most require running both systems in parallel while you verify parity, so plan for paying for and operating two systems during that period.

How to implement and revisit the decision: triggers and a design-review checklist

A build-or-buy decision is correct for the conditions under which it was made, and those conditions change. Early-stage teams usually buy because engineering time is the scarcest resource and vendor pricing is cheap at low volume. At larger scale the math can invert: usage-based pricing grows with traffic while an in-house service's cost grows mostly with headcount, so some services become cheaper or easier to control internally. The reverse also happens. A tool you built years ago may now have a mature managed equivalent, and keeping it means paying a team to maintain something that no longer differentiates you.

Put a review date on every decision record, typically annually, and reopen it early when a trigger fires:

  • The bill crosses a threshold you set in advance, such as a fixed share of infrastructure spend. Choosing the threshold before you hit it prevents both panic and inertia.
  • Repeated incidents trace back to the same system, whether that is vendor outages or failures in your own tool.
  • The owner leaves and nobody else can confidently operate or change the system.

Decision record template

  • Differentiation: Does this capability set us apart, or is it commodity?
  • Multi-year TCO: Projected cost over several years, including engineering time and migration.
  • Staffing and on-call: Who owns it, and is the rotation sustainable?
  • Compliance constraints: Data residency, audit, or certification requirements.
  • Exit cost and reversibility: What it takes to switch, and how long parallel running would last.
  • Review date and triggers: When we revisit, and what forces an earlier look.
id: ADR-0042
title: Managed message queue vs self-hosted cluster
decision: buy            # buy | build | retire-built-tool
decided_on: 2026-10-20
owner: platform-team     # a team, not a person, so the record survives turnover

differentiation: commodity   # we gain no product advantage from running this

tco_3yr:                 # project several years, not just the first invoice
  vendor_fees_usd: 90000
  engineering_hours: 300     # integration, upgrades, incident response
  migration_out_usd: 25000   # estimated cost to leave later

staffing:
  on_call_rotation: platform-primary
  min_people_who_can_operate: 2  # below this, treat as the owner-leaves trigger

compliance:
  data_residency: eu-only
  audit_logs_required: true

exit:
  reversibility: medium
  parallel_run_weeks: 6      # how long old and new would run side by side

review:
  next_review: 2027-10-20    # scheduled annual revisit
  triggers:                  # any one of these reopens the decision early
    - monthly_bill_share_of_infra_spend_exceeds: 0.10
    - incidents_traced_to_system_in_90_days_exceeds: 3
    - operators_available_below: 2

A YAML decision-record template that captures differentiation, multi-year TCO, staffing, compliance, exit cost, and the scheduled review date plus the triggers agreed in advance for reopening a build-or-buy decision early.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to build vs buy infrastructure, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.