Why monolith deploy frequency is limited by batching, not the artifact
Trunk-based development for a monolith means every engineer merges small changes into one main branch at least daily, keeps that branch releasable at all times, and deploys the single artifact straight from it. Teams adopt it because a monolith's deploy frequency is rarely capped by the artifact itself. Building and shipping one binary is usually quick. What slows deploys down is the process around it: release branches cut on a schedule, merge freezes while the branch stabilises, and a manual QA sign-off that runs once per batch. Each of these steps lets changes pile up, and the pile is where the risk sits.
Bigger batches make each deploy riskier. More changes interact in ways no one reviewed together. When something breaks, you first have to work out which change caused it before anyone can fix it.
Illustrative estimate (assumed inputs, not measured)
Assume a team produces 150 commits a week. Compare one weekly train against hourly deploys over a 40-hour week, which gives about 4 commits per deploy.
| Weekly release train | Hourly deploys | |
|---|---|---|
| Commits per deploy | ~150 | ~4 |
| Worst-case bisect steps (log₂ n, rounded up) | 8 | 2 |
| Changes reverted by a rollback | ~150 | ~4 |
| Unrelated work delayed by a rollback | The whole week's | About an hour's |
With hourly deploys the artifact is the same. The difference is that a failure points to a few commits, and a rollback leaves the rest of the team's work in production.
How trunk-based development works with one deployable
Every engineer merges into main at least once a day. A branch lives for a few hours, two days at most. The rule exists because merge pain grows with the time two changes spend apart. A branch that is a day old conflicts with about a day of other people's work. A branch that is two weeks old conflicts with everyone's work, and the team usually responds by batching again.
main must stay releasable at all times. Any commit on it could go to production within the hour, so "I'll fix it in the next PR" is not acceptable. Unfinished work can be merged, but it must be dark or unreachable.
short-lived branch ──► merge queue ──► main pipeline ──► build once
(hours–1 day) (rebase + test (full suite on (one versioned
against main) merged result) artifact)
│
production ◄── canary (small % of traffic) ◄── staging ◄─┘
promote the same artifact, unchanged
The merge queue tests each change against the current main plus whatever is queued ahead of it. Two changes that each pass on their own but break when combined are caught before they reach main.
The artifact is built once and then promoted through each environment. It is never rebuilt per environment. What canary verified is byte-for-byte what production runs, and only configuration differs between environments.
A monolith has no service boundaries to give teams independence, so the pipeline provides it. Teams stay out of each other's way because each change is small, tested in isolation, and quick to roll back.
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ short-lived │ │ merge queue │ │ main pipeline│ │ build once │
│ branch │──►│ rebase on │──►│ full suite on│──►│ one versioned│
│ hours-1 day │ │ main + queue │ │ merged result│ │ artifact │
└──────────────┘ └──────────────┘ └──────────────┘ └──────┬───────┘
│
┌──────────────┐ ┌──────────────┐ ┌──────┴───────┐
│ production │ │ canary │ │ staging │
│ same bytes │◄──│ small % of │◄──│ same artifact│
│ prod config │ │ traffic │ │ stg config │
└──────────────┘ └──────────────┘ └──────────────┘
Built once from main, then promoted unchanged. Only config differs.
A short-lived branch passes through the merge queue and the main pipeline into a single build, and that one artifact is promoted unchanged through staging and canary to production.
Keeping short-lived branches small: slicing, branch by abstraction and feature flags
Small changes only stay small if the work is sliced so each merge is safe to deploy, even when the feature it belongs to is weeks from done.
Branch by abstraction handles large refactors. Put an interface in front of the code being replaced, move callers onto it, build the new implementation behind it, switch over, then delete the old path. Every step is a mergeable commit, and nobody has to rebase a long-lived branch against a trunk that moves dozens of times a day.
Expand and contract handles interface changes. Add the new method or field alongside the old one, migrate callers, then remove the old one in a later deploy. At no point does a single commit break a caller.
Feature flags let incomplete code ship dark. Deploying puts code in production. Releasing exposes it to users. A flag separates the two, so engineers can merge freely while product decides when users see the change.
| Release toggle | Ops / kill switch | |
|---|---|---|
| Purpose | Hide unfinished work | Shed load or disable a dependency |
| Lifetime | Days to weeks | Long-lived, by design |
| Default | Off | On (feature enabled) |
New flags should default to off. Each flag needs a named owner and an expiry date, as in the example below, and CI should test both states. Treat unremoved flags as debt. Every live flag doubles the number of possible code paths, and untested combinations cause incidents. A flag flipped in the wrong environment can do as much damage as a bad deploy, so audit flag changes the same way you audit code changes.
from dataclasses import dataclass
from datetime import date
@dataclass(frozen=True)
class Flag:
name: str
owner: str # team accountable for removing the flag
expires: date # past this date, CI fails until the flag is deleted
kind: str # "release" or "ops"
default: bool # release toggles default to off
FLAGS = {
"new_checkout_flow": Flag("new_checkout_flow", "payments-team",
date(2026, 12, 1), "release", default=False),
# Kill switch: long-lived by design, enabled unless someone turns it off
"recommendations_enabled": Flag("recommendations_enabled", "discovery-team",
date(2027, 6, 30), "ops", default=True),
}
def is_enabled(name: str, overrides: dict[str, bool]) -> bool:
flag = FLAGS[name] # unknown names raise instead of silently returning False
return overrides.get(name, flag.default)
def check_expired(today: date) -> list[str]:
# Run in CI so stale flags block merges and become visible debt
return [f"{f.name} (owner: {f.owner}) expired {f.expires}"
for f in FLAGS.values() if f.expires < today]
# Call site: the old path stays the default until the flag is flipped
def checkout(cart, overrides):
if is_enabled("new_checkout_flow", overrides):
return new_checkout(cart)
return legacy_checkout(cart)
# Tests should exercise both states, e.g. with pytest:
# @pytest.mark.parametrize("on", [True, False])
# def test_checkout(on): checkout(cart, {"new_checkout_flow": on})
A minimal flag registry where each flag records an owner, an expiry date, its type and a default, with a CI check that reports expired flags and a call site tested in both states.
Database schema changes when every deploy shares one schema
Schema changes need the same discipline as flags. During a rolling deploy, old and new instances serve traffic against a single database. A rollback also puts the old code back on whatever schema now exists. So every migration has to leave the schema usable by both the current release and the previous one. Run migrations as their own pipeline step, ahead of the code that depends on them, so a deploy never waits on a migration and a migration never assumes code that hasn't shipped yet.
Renaming users.fullname to display_name with expand and contract:
- Expand.
ALTER TABLE users ADD COLUMN display_name text NULL;A nullable column with no default is a cheap metadata change in most engines. - Dual-write. Deploy code that writes both columns and still reads
fullname. - Backfill. Run
UPDATE users SET display_name = fullname WHERE display_name IS NULL AND id BETWEEN ? AND ?in small batches, so no single statement holds row locks for long or floods replication. - Switch reads. Deploy code that reads
display_name, then a later release that stops writingfullname. - Contract. Once no running or rollback-eligible version references it,
ALTER TABLE users DROP COLUMN fullname;
Statements that rewrite a table or take exclusive locks, such as adding a non-null constraint or building an index, need lock-avoiding variants or batching.
The cost is real. One rename becomes four or five deploys spread over days. After the contract step drops data, rolling back is no longer an option, so fixes must roll forward.
-- PostgreSQL. Each block is a separate migration, shipped in its own deploy.
-- Migration 1 (EXPAND): runs before any code that knows about display_name.
-- Fail fast instead of queueing behind long transactions and blocking traffic.
SET lock_timeout = '5s';
-- Nullable, no default: a metadata-only change, no table rewrite.
ALTER TABLE users ADD COLUMN display_name text NULL;
-- (Deploy: app writes fullname AND display_name, still reads fullname.)
-- Migration 2 (BACKFILL): a job repeats this until it reports 0 rows updated.
-- Small batches keep row locks short and replication lag low.
UPDATE users
SET display_name = fullname
WHERE id IN (
SELECT id FROM users
WHERE display_name IS NULL
AND fullname IS NOT NULL -- skip rows with no value so the loop ends
ORDER BY id
LIMIT 1000
);
-- (Deploy: app reads display_name. Later deploy: app stops writing fullname.)
-- Migration 3 (CONTRACT): only after no running or rollback-eligible
-- release references fullname. This discards data: roll forward only.
SET lock_timeout = '5s';
ALTER TABLE users DROP COLUMN fullname;
A PostgreSQL rename of users.fullname to display_name split into separate expand, batched backfill and contract migrations, with a lock timeout so schema changes fail fast instead of blocking live traffic.
Implementing a fast, trustworthy main-branch pipeline and safe deploy mechanics
the pipeline that ships each of those steps has to be fast enough to run many times a day and trusted enough that nobody second-guesses a green build.
Speed
Run only the tests affected by a change, using the build's dependency graph, and shard the rest across parallel runners. For very large monoliths, split the codebase into internal modules with declared dependencies so a build cache can skip unchanged modules. Without that, every commit rebuilds everything.
Trust
Quarantine flaky tests immediately. Each quarantined test gets a named owner and a deadline, because quarantine with no owner turns into permanent deletion. A merge queue tests each change against the current main plus everything queued ahead of it, which keeps main green when many people merge per hour. The rule for a red main is that the merging author fixes or reverts it within minutes. Reverting is the default.
Deploy mechanics
Deploy automatically on every green main, or on a fixed short cadence. Roll out to a canary first, then widen in stages. Tie error rate and latency to automatic rollback, or at least to rollback with a single command.
Roll forward or back
Roll back when the cause is unclear. Roll forward only when the fix is small and the cause is understood. Cache entries, message formats and emails sent cannot be reverted. Make readers tolerate both old and new formats before any writer changes.
Attribution
Emit a deploy marker that lists the included commits. Map paths to owners with CODEOWNERS, for example /billing/ @org/payments. Route each alert to the team that owns the failing code path.
# .github/CODEOWNERS (GitHub also reads CODEOWNERS at the repo root or in docs/)
# Syntax: <path pattern> <owner> [<owner> ...]
# The LAST matching pattern wins, so put broad defaults first
# and specific paths below them.
# Default owner: the team on call for main when nothing more specific matches
* @example-org/platform
# Business domains inside the monolith, one owning team each.
# The same mapping routes alerts: a failing code path resolves to one team.
/billing/ @example-org/payments
/checkout/ @example-org/payments
/accounts/ @example-org/identity
/search/ @example-org/discovery
/notifications/ @example-org/messaging
# Shared message and cache schemas: changes need both producer and consumer
# teams, because old and new formats must both be readable before a writer changes.
/shared/schemas/ @example-org/messaging @example-org/payments
# Pipeline and deploy mechanics (merge queue, canary, rollback scripts)
/.github/workflows/ @example-org/platform
/deploy/ @example-org/platform
# Quarantined flaky tests: platform reviews every quarantine change so each
# entry keeps a named owner and deadline instead of silently becoming permanent
/test/quarantine/ @example-org/platform
A CODEOWNERS file that maps monolith paths to owning teams. The same mapping supports review routing, alert routing and accountability for quarantined tests.
Who should adopt it, when to avoid it, and the alternatives
Where it fits
Trunk-based development suits teams that run a hosted monolith they control end to end: a web backend or internal platform where a deploy reaches every user within minutes and a rollback is a redeploy. It works best when a handful of teams share the codebase and module ownership is clear. If teams already merge daily and the pain is a slow, batched release, this is the change that helps most.
When batching is legitimate
Some constraints require batches. Examples include regulated change approval that needs a sign-off per release, installed or on-prem software that customers upgrade on their own schedule, mobile clients that go through store review, and contractual release windows. In these cases you can still keep trunk always releasable and build every commit, but add a separate release step that promotes a chosen build.
The shared-deployable limit
Trunk-based development makes batches smaller. It does not isolate teams. All changes ship in one artifact, so one team's bad commit can force a rollback that also removes every other team's work. When this happens often, the cause is coupling, and better branching will not fix it.
| Approach | Batch size | Team isolation | Fits when |
|---|---|---|---|
| Release trains | Large, scheduled | None | Fixed external release dates |
| GitFlow-style long-lived branches | Large, merge-heavy | None | Several supported versions in parallel |
| Continuous delivery with a manual release step | Small builds, gated release | None | Approval or customer-controlled upgrades |
| Extract high-churn or high-risk components | Small, per component | Real | Repeated cross-team rollbacks |
Migrating off release trains and the impact on cost and engineering time
Migrate in phases, and do not start a phase until the previous one is stable.
| Phase | Change | Metric to watch |
|---|---|---|
| 1. Shorten the train | Move from biweekly to weekly, then twice weekly. Cut release branches from main at release time and delete them after the deploy. | Deploy frequency, change failure rate |
| 2. Stop long-lived branches | Hotfix on main and redeploy. Never backport. | Lead time from merge to production |
| 3. Move QA earlier | Test before merge or behind flags in production, not in a staging soak. | Change failure rate, escaped defects |
| 4. Continuous deploy | Every green main commit deploys. | Time to restore, rollback rate |
Most of the cost comes first. You build a faster pipeline, adopt flag tooling and schedule flag removal. The culture also has to change: reviews happen within hours, and a red main is an interrupt that someone fixes now.
Partial adoption is often worse than either model. If you deploy daily but keep a manual QA gate, you pay for both a fast pipeline and slow coordination. If you add flags but never delete them, you get two code paths and full test coverage of neither.
The savings come from smaller batches:
- Merge conflicts become rare.
- Freezes, go/no-go meetings and release-manager rotations go away.
- Bisecting a bad deploy means searching a handful of commits instead of a hundred.
- An incident affects one small change, so it is quicker to diagnose and revert.
To estimate coordination time saved, multiply engineers × hours per release spent on freezes and sign-off × releases avoided per year. Use your own figures.
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 trunk based development monolith, 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.