Skip to main content
Architecting Software in 2026 · 3 of 6 partsEngineering28 min read

Database per Service Is Free Now. That Is Not a Reason to Use It

A free tier that doubles changes the inputs to a decision, not the decision. In week one I predicted Bullpen would split prices and history into two services with two databases. Then Neon made database per service look free, and I had to decide whether that changed anything. This is the decision history: five data and deployment decisions, each with its conditions, the options I weighed, why one won, and what would make me reverse it.

← All Posts
2/4

Database per service is the default advice for microservices, and on October 2 it got cheaper. Neon doubled its free storage to 1 GB per project, and the free plan still allows 100 projects. A database per service now multiplies my free allowance instead of my bill. That changed an input to the decision. It did not make the decision for me.

In my first week on Bullpen, I predicted that Part 3 would split stock prices and market history into two services, with a database per service. That prediction is frozen at the tag predictions-week-1. Then the build happened, and most of it did not survive. Bullpen now keeps all its data in one Neon database, with one schema and one database role per owner, and a single owner for all market data. The rule that came out of it is about transactions: the authoritative data stays where the transaction ends, not where the free tier ends.

This part is written differently from the first two. It is a decision history: each decision with the conditions I faced, the options I weighed, what I chose, why, and what would make me reverse it. I am running Bullpen as a test, building it to find out what is new in 2026 and which parts hold up and which do not. There is no universally right architecture, but there are wrong ones for a given set of conditions, and the conditions are what this post is about.

Where Parts 1 and 2 left the data question

Part 1, on when to use microservices in 2026, set the rule for the whole series. Every split has to cite one of five reasons: data contention, failure isolation, independent change, runtime fit or a shared budget. Each reason buys a boundary, and only a boundary that needs its own deploy, its own data and its own failure domain earns a microservice. Part 1 also promised that Part 3 would bring the evidence for the first reason, data contention, and warned about the trap this post walks into: a cheap pattern is not a reason.

Part 2, on the three jobs of architecture as code, set up two records. The planned architecture in planned/ is my current plan, and I change it when I change my mind. The tag predictions-week-1 keeps what I predicted on day one, and Part 6 compares the two. Part 3 is the first time that discipline meets a real reversal.

Part 1 also needed a correction. It said Neon gives each free project 0.5 GB. That was already out of date when the post went out on October 4, two days after Neon's announcement. The post now says 1 GB, with a dated correction note under the paragraph instead of a silent edit. The same rule applies to the architecture: when the facts move, the record shows when.

Disintegrators, integrators and the five-step decision record

For the split-or-keep questions, I use the frame from Ford, Richards, Sadalage and Dehghani's Software Architecture: The Hard Parts. One set of forces pulls a system apart, which they call disintegrators: a different rate of change, a different scaling or fault-tolerance need, a different security boundary. A second set holds it together, the integrators: a database transaction, a workflow that needs tight coordination, shared code, related data. A split is worth it when the disintegrators outweigh the integrators for the conditions at hand.

Every decision below follows the same five steps:

  1. Conditions: the limits and facts I had on the day I decided.
  2. Options: the approaches I took seriously.
  3. Choice: what I picked.
  4. Why: which forces won.
  5. What would flip it: the observation that would make me reverse it.

The conditions come mostly from free tiers. Here are the ones that shaped Part 3, as of October 13, 2026:

PlatformFree limitWhat it pushes toward
Neon1 GB and 100 CU-hours a month per project, 100 projectsMany small databases look free
NeonCompute scales to zero after 5 idle minutesEvery database wakes up cold on its own
Vercel Hobby1 concurrent build, 100 deployments a dayFewer projects that build together
Alpha Vantage25 stock data requests a dayOne owner for the whole budget

The sources are Neon's plans page, Vercel's limits and Alpha Vantage's support page. The table already shows the tension. Neon's limits reward splitting the data. Vercel's and Alpha Vantage's limits reward keeping things together.

One limit is missing from that table, and it is the one that stopped the build: how many bytes a free Neon project may send each month. Decision 5 is where I found it.

The picture below compares the two architectures: the week-one prediction on the left, and what Part 3 built on the right. Look at the number of databases and at the single line Trading is allowed to draw into market data.

Database per service or one owner per transaction: three data decisions

Decision 1: one owner for all market data

Conditions. Week one predicted two services: Prices for the latest quote, and Market History for past prices. When I chose a stock data source, the free option was Alpha Vantage, at 25 requests a day. That is enough for end-of-day prices on a short list of symbols, fetched once a day, and nothing more. Crypto prices come from CoinGecko on a different budget. Alpha Vantage also offers unlimited requests for verified open-source or educational projects, but I designed for the default limit, because that is the condition today.

Options.

  • Keep the prediction: a Prices service and a History service, each with its own database.
  • Merge them into one Market Data owner, inside the trading app.
  • Merge them into one Market Data owner, as its own boundary.

Choice. One Market Data owner, as its own boundary. It holds both providers and both budgets.

Why. I listed the disintegrators and found none. Prices and history come from the same provider, change at the same rate and fail in the same way. The integrator was strong: both would spend the same 25 requests. Two services sharing one budget means either a coordination protocol between them or a budget that can be overspent. Part 1's fifth reason, a shared budget, says one owner should hold a limit that everyone draws from.

In week one I split by type of data. The build showed me that the external constraint was the thing that needed one authority.

One owner does not by itself require a separate boundary. A module inside the trading app could hold the budget just as well. I made Market Data its own boundary for two concrete reasons and one honest one. It runs on a different schedule from everything else: one ingest a day after the close, not per request. It keeps both provider keys and both budgets behind one interface, so no other code can spend a call. And it is the cheapest place in Bullpen to test Vercel Services, which decision 4 covers. If that experiment shows no benefit, Market Data folds back into a module and the ownership rule stays the same.

The lesson is narrower than "never split prices from history". With a streaming source, a large intraday history and a different retention policy, the history store could have a scaling need the latest price does not. Bullpen has none of that.

What would flip it. A workload where history ingestion and latest-price updates develop materially different scaling, retention, runtime or failure-isolation needs. A second provider alone would not be enough: one Market Data owner can coordinate two providers. It would have to make the separation worth its cost.

Decision 2: where the data lives

Conditions. Neon's free plan gives each project its own storage, compute-hours and compute. Each compute scales to zero after 5 idle minutes, and the free plan does not let me turn that off. On the smallest compute, 0.25 CU, Neon's docs list 104 Postgres connections, with 7 reserved for Neon, per its connection pooling docs. Neon's pooler keeps a pool per role and database, and every pool draws from those same backend connections. The leaderboard has to combine positions from Trading with prices from Market Data.

Options. Microservices.io lists three ways to keep a service's data private: private tables per service, a schema per service, or a database server per service. On Neon that becomes:

  • A project per service. Each owner gets its own 1 GB, its own 100 CU-hours a month and its own connection pool. It also gets its own cold start, its own credentials and its own migrations. A query that needs data from two owners becomes two network calls and a join in application code.
  • Several databases in one project. They share the compute, so they share its cold start and its connection limit. Postgres cannot join across databases without an extension, so the leaderboard still joins in code.
  • One database, one schema and one database role per owner. One cold start, one compute's connections, and ownership enforced by grants instead of by distance.

Choice. One Neon project and one database, with a schema and a role per owner: market_data owned by market_data_owner, and trading owned by trading_owner. Market Data publishes one read-only view, market_data.prices_v1, and Trading may read that view and nothing else in the market_data schema.

Why. The project-per-service option looked better on paper: more storage, more compute-hours, more connections, all free. It also meant two databases waking up cold on their own schedules, and a leaderboard query split across a network call. None of the five reasons asked for that. The free allowance did, and a free allowance is not a reason. The extra connections did not decide it either. With two roles, Bullpen has two pools that share one compute's backend connections, and no part of Bullpen has a measured need for more than one compute gives. The market-open load test in Part 5 is where that gets measured.

What separate databases really protect against is the failure Martin Fowler describes as an integration database: two or more applications reading and writing the same tables until nobody can change a column. Grants guard against that failure too, and they fail loudly. Under this design, a query from Trading against a Market Data table is refused by Postgres, and a test in CI is built to prove it.

On the production branch, connected as trading_owner, a select from the quotes table comes back as 42501 permission denied for table quotes, and the same role reads market_data.prices_v1 without an error. The reverse holds too: market_data_owner is refused every read and write on the trading schema. The same denials run as tests in CI against a Postgres container.

One number surprised me, twice. I expected SHOW max_connections to return 104. It returned 901 on October 13, and 901 again on the morning this post went out. Neon sets the limit from the largest size the compute is allowed to reach, and 901 is the figure for 2 CU. So the compute I believed was fixed at 0.25 CU could scale up to 2 CU, and it could during every measurement below. The numbers stand as measured; the headroom behind them was larger than I thought. The lesson is the same one this whole part keeps teaching: read the setting from the system, not from my notes.

I also changed one detail during planning. My first sketch had a role per code module. A reviewer pointed out that roles should follow transactions, not modules. Orders and Ledger can be two modules in the code and still share one owner, because they share one transaction. That is decision 3.

What would flip it. A real need for isolation that grants cannot give: a workload whose load hurts the other owner's queries even when they touch different tables, or an owner that needs its own failure domain. The contention test below is how I would see the first one.

Decision 3: Orders and Ledger stay in one transaction

Conditions. My September outline for Part 3 split Orders from Ledger. A buy has to reserve buying power, record the order, write the ledger entries and update the position. Buying power can never go negative, even when two buys race.

Options.

  • Split them into two services and coordinate with a saga: each step commits on its own, and a failure runs compensating steps.
  • Split them and coordinate with a distributed transaction such as two-phase commit.
  • Keep both under one owner, in one database transaction.

Choice. One owner, trading, and one transaction for the order and its ledger entries.

Why. This was the strongest integrator in the whole build. The Hard Parts treats a database transaction as a reason to keep services together. Splitting them means replacing an atomic commit with something weaker, and I had no reason that justified the cost. Two-phase commit blocks participants when the coordinator disappears, which is what my notes on two-phase commit on the JVM showed. A saga gives up atomicity for compensation.

Both are real tools, and Part 4 is where Bullpen needs a saga: the order lifecycle, with partial fills and cancels. For the write that moves money, the rule I wrote into the decision record is short. The authoritative order and ledger writes stay inside one transactional boundary. A split needs a stated replacement for that atomicity first.

A CHECK (buying_power >= 0) constraint is not enough on its own. It checks the row that gets written, but if two transactions each read the balance, compute a new one in application code and write it back, the second write can overwrite the first and both buys pass. So the reservation is one conditional update, run inside the same transaction as the order and ledger writes:

sql
UPDATE trading.accounts
SET buying_power = buying_power - $1
WHERE account_id = $2
  AND buying_power >= $1
RETURNING buying_power;

Postgres takes a row lock for the update, so the second of two racing buys waits, then re-checks the condition against the balance the first one left. If no row comes back, the buy is refused and the transaction rolls back.

I ran that race locally on PostgreSQL 16.13 before writing this: two buys of 600 against a buying power of 1,000, each holding its transaction open for a second. One committed and left 400; the other got no row back and rolled back.

The CHECK constraint stays as a second guard. The integration test has to show three things: two buys that each fit but together overspend cannot both commit; the losing buy leaves no order, ledger entry or reservation behind; and a failure injected after the order insert rolls back every write.

The integration test does all three. Two buys of 6 units at 100 race against a buying power of 1,000. Exactly one commits; the other is refused as insufficient-buying-power. Only the winner's writes exist afterwards: one order, two ledger rows, one position and 400 of buying power left. A failure injected after the order insert leaves no order row and no ledger row.

What would flip it. A team that needs to ship the ledger without the order flow, or a ledger whose write load contends with order entry badly enough to measure. Neither exists in a one-person project, and Part 6 is where I sketch where team boundaries would sit.

Deployment and reading: two decisions about what ships and what readers see

Decision 4: the first extraction ships as a Vercel Service

Conditions. Market Data is the first part of Bullpen with its own data, its own budget and its own schedule. That makes it the first extraction, and it forces the deploy-topology decision I left open as ADR-0004 in week one. On Vercel Hobby, only one build runs at a time, and a project can create 100 deployments a day. Since June 2026, Vercel Services lets one project hold more than one separately built service, in beta on every plan.

Options.

  • Keep Market Data as a module inside the trading app.
  • Put it in its own Vercel Service, inside the same project.
  • Put it in its own Vercel project.

Choice. A Vercel Service in the same project.

Why. Separate projects would give Market Data an independent deploy, and Bullpen does not need one: one person ships everything, and Part 1 already expected independent change to be the weakest of the five reasons here. On Hobby, separate projects would also compete for the account's single build slot whenever a push triggered builds in more than one of them. A Service builds separately inside one shared deployment instead, so the boundary is visible in the build and in the code without a second project to keep in step. The experiment below measures whether that saves anything for this repository; if it does not, I report that too.

The Services docs are plain about the cost: "All services share the same deployment." So Market Data is a boundary with a shared release, not a microservice. By Part 1's rule, that is the honest name for it: it has its own data and its own budget, but not its own deploy or its own failure domain. I record the shared release in ADR-0004 as a deliberate coupling, so nobody reads the diagram later and assumes the services ship independently.

I pushed three commits under each of the first two options and timed each deployment. The numbers are medians of three:

MetricModule in appVercel ServiceSeparate project
Push until the web deployment is ready70.9 s42.7 snot run
Queued behind another build8.7 s4.0 snot run
Web app build55.2 s31.6 snot run

The Service looks faster, but the comparison is not clean. In two of the three Service runs only Market Data changed, so the web app's build came from Turborepo's cache. Part of the gap is that an unchanged service is not rebuilt, and part is cache luck; the runs do not separate the two. What I trust more are three findings that do not depend on timing. The module option only passed my architecture rules after I loosened one: inside one app, the boundary between Market Data and Trading is a convention, and nothing stops an import across it. A push that changed only the landing page still started a deployment of the web app, which was cancelled later. And a Vercel Service without a framework needs an explicit runtime and entry point; without them, Vercel builds it as a static site and the build fails looking for an output folder.

I did not build the separate-project option. The first two answered the question I had, and a second project would have added a second set of settings to keep in step for no measured gain.

What would flip it. A need to roll Market Data back without rolling back the trading app, or build queuing that the numbers show is slowing every push.

Decision 5: the leaderboard reads a materialized view, not a queue

Conditions. The leaderboard values every player's positions at current prices and ranks them. Stock prices change once a day, after the close. Crypto prices refresh every few minutes. Part 1 named the risk: at 9:30, leaderboard reads compete with order writes for the same database. Bullpen does not have a crowd at the open yet, so that load is a prediction, not a measurement.

Options.

  • Compute on read: run the ranking query on every request.
  • A materialized view: store the ranking in Postgres and refresh it when prices or fills change.
  • A read model fed by a queue: publish an event for every price change and fill, and update a separate leaderboard store from those events.

Choice. A materialized view, trading.leaderboard_mv, built from positions and the prices_v1 view. It refreshes after the daily price ingest, and when a read finds it older than 5 minutes. No reader waits for a refresh. A stale read returns the current ranking with its timestamp and starts a refresh after the response, guarded by a non-blocking Postgres advisory lock: if another refresh holds the lock, the request skips it. A unique index lets the refresh run CONCURRENTLY, so readers keep selecting the previous ranking while it runs. If a refresh fails, readers keep the last good ranking and the page shows its age. That makes the page at most one refresh behind, which the leaderboard can tolerate.

Why. The leaderboard can tolerate minutes of staleness, so I did not need it updated on every write. That ruled out nothing on its own; it made the simpler options sufficient. Between the two simpler ones, the view moves the expensive read off the ledger tables, which is the contention Part 1 worried about.

The queue was the option a reviewer suggested, and I considered it seriously. Vercel Queues delivers at least once, does not guarantee order and has no built-in dead-letter queue. A correct read model on top of it needs stable event IDs, a version per account so that an older event cannot overwrite a newer one, and a rule for messages that keep failing. That is a fair price for a real workload. Without a second consumer of those events and without a measured read load, it would have been a technology demonstration.

I wrote about what view maintenance does and does not guarantee in my notes on incremental view maintenance; a plain Postgres view refresh is the much simpler cousin.

A view can drift from its source, so it gets a reconciliation check, an idea I took from Neal Ford's trade-off analysis session on October 1: once the system is quiet, the derived data must equal the data recomputed from the source of truth. In CI, the check compares the view with the same ranking computed directly from the tables. It is built to fail, naming the account, on a fill after the last refresh and on a tampered row in the view.

Both failures name the account. The fill case is the more interesting one: the view and the tables agree on equity, 1,000, and still disagree on what it is made of. The view says 1,000 in cash and nothing in positions; the tables say 600 in cash and 400 in positions. A check on the ranking alone would have passed. For the tampered row, the view claims 9,999 against 1,000 from the tables.

The data-contention evidence. This is the measurement Part 1 promised. It is a simulated burst, not a live market open: a fixed batch of buys runs while 0, 10 and 50 concurrent readers hit the leaderboard, first through the compute-on-read query and then through the view under its real refresh policy, including the first read after the view goes stale. A refresh is itself a heavy query, so the test also records how long it takes and what it does to ledger writes. If the view speeds up reads without improving ledger writes, that proves the value of a read model, not the need for a split.

The first run did not answer the question. It stopped the database. I seeded 2,000 accounts, and every reader fetched the whole leaderboard: about 360 KB per answer. One 50-reader cell moved about 1.7 GB out of Neon. On October 16 the project passed the free plan's 5 GB of network transfer for the month, and Neon suspended the compute. I had counted storage, compute-hours and connections. I had not counted bytes leaving the database.

That run still showed a shape. At 50 readers, ledger writes took 1,275 ms at p95 with compute on read and 1,005 ms with the view. But when I looked at where the time went, it was not CPU, locks or the connection pool. It was the size of each answer. The database was busy sending, not computing.

So three things changed before the second run, and the comparison has to be read with all three in mind. The leaderboard API now returns the top 50 players plus the caller's own row, about 9 KB instead of about 360 KB when I measured both on a local copy with 2,096 accounts. The test seeds 200 accounts and runs each cell for 15 seconds, behind a guard that refuses to start when its estimate passes 10% of the remaining transfer allowance. And the project moved to Neon's paid Launch plan, on the same compute settings.

The second run: 8 writers placing buys, on a compute that could scale up to 2 CU, 200 accounts, 15 seconds per cell. The client was my laptop over the public internet, so every number includes a round trip that a function in the same region would not pay. Compare the rows with each other, not with a production latency.

ReadersRead pathLedger write p95Read p95
0none575 msn/a
50compute on read1,007 ms810 ms
50view613 ms353 ms

At 10 readers the two paths were indistinguishable on writes. At 50, computing the ranking on every read added 75% to ledger writes; the view kept them within 7% of the idle baseline. Reads from the view stayed under the 500 ms line I set for switching to a queue; reads computed on the fly did not. The refresh itself cost nothing I could measure: 36 back-to-back refreshes took 424 ms at p50 and 455 ms at p95, and ledger writes ran at 573 ms p95 during them against 618 ms without.

So the contention Part 1 predicted is real, and for the workload I tested, a read model inside the same database removed it. Data contention does not earn Bullpen a split yet. The test says nothing about a crowd ten times larger or a different mix of reads and writes; the switch criteria below are for that. For this workload, a separate leaderboard database would have sent the same bytes over one more network hop, and added a second database to wake up.

One more number is worth keeping. I estimated the second run would move 185.6 MB; it moved 56.2 MB. The bytes per read matched my estimate, between 8.2 and 9.5 KB. The estimate assumed each reader asked 10 times a second. A reader that waits for its answer before asking again managed about 3. A load test like this measures what the system lets readers do, not what the readers want.

What would flip it. I wrote the switch criteria into the decision record before measuring anything. I move to a queue-fed read model when a second consumer needs price or fill events, or when leaderboard reads pass 500 ms at p95 at the open.

Operations: the smaller decisions

The remaining decisions mattered, but none of them changed the shape of the system. Each one still has a condition, a choice and a reversal.

DecisionChoiceEvidenceWhat would flip it
Cold database at 9:30Accept the wake-up and measure it against a target set firstCold p95 2,048 ms, target 3,000 msCold p95 over the target, or a failed trade
Stock price scheduleOne daily cron, after the closeA preview ingest stored AAPL and MSFT for October 16Intraday prices, or a paid plan
Market data budgetRefuse calls over 20 a day (5 on previews) before they leave60 callers, cap 20: 20 granted, 40 refused, 0 overA larger provider limit
Proposed decisionsOnly an Accepted ADR authorizes a nodeFails while Proposed, passes when AcceptedNone; this was a bug

Cold start. Neon's scale-to-zero docs say a suspended compute reactivates within a few hundred milliseconds, and that the setting is fixed on the free plan. Hobby cron fires anywhere inside its scheduled hour, per Vercel's Hobby plan page, so a cron cannot reliably warm the database just before 9:30. A precisely timed external scheduler could, but that is a new dependency for a problem I had not measured.

So I committed the targets before measuring anything. A request is cold when nothing has called the deployment for at least 20 minutes, and it is timed from my laptop, so the targets include the internet round trip: cold within 2.0 seconds at p50 and 3.0 seconds at p95, warm within 0.3 seconds at p50 and 0.8 seconds at p95 for the leaderboard.

All of them held. The leaderboard, which reads Neon, answered cold in 1,013 ms at p50 and 2,048 ms at p95, and warm in 141 ms and 270 ms. The price route, which does not touch the database, was slower cold, at 2,361 ms p95, and I do not know why yet. The run also fell short of its plan: 8 and 9 cold samples instead of 10, because my laptop dropped connections when it went to sleep. I kept the failures out of the percentiles and wrote them down instead.

The status-aware drift check. Part 2 introduced a check that fails when the architecture model names a provider that no decision record supports. A reader pointed out a gap: the check accepted a Proposed ADR's decision as if it were final. That meant I could rename the stock provider node to Alpha Vantage while the decision was still under review, and CI would pass. The check now treats a Proposed decision as undecided. Its test runs in both directions. With ADR-0008 set back to Proposed, the check fails three times: on the Part 3 moment and on the planned Parts 4 and 5, which carry the same node. Each failure says that Alpha Vantage is still undecided and how to fix it. With ADR-0008 Accepted, it passes.

What the build changed in the records

Four decision records come out of this part: ADR-0004 for the deploy topology, ADR-0012 for data ownership, ADR-0013 for the single Market Data owner and ADR-0014 for the leaderboard view. The ownership record carries the Orders and Ledger rule. They were Proposed during the build and are Accepted with this post. Two more are about the records themselves: ADR-0015 ties the architecture rules to the latest built moment, and ADR-0016 says only an Accepted decision authorizes the model.

The architecture model changed in two places. The Part 3 moment describes what actually runs: the market-data service, one database with two schemas, and the ownership relationships that say who may connect to what. The planned moments for Parts 4 to 6 no longer carry the Prices and History split, because planned/ is my current plan. The tag predictions-week-1 did not move. On the architecture page, the Part 3 "What changed" panel shows the prediction next to what I built.

The page also shows traffic now. Every connection carries a rate with one of three labels: measured, from the load test and not from users; configured, a schedule or a cap written in the repository; or illustrative, meaning the connection is used but nobody has measured how much. Bullpen has no production users yet, so most lines are illustrative, and the page says so.

The trading app now follows its design, but the order ticket is disabled. Trading opens with sign-in in Part 4.

What Part 3 cost

Part 3 was meant to run on free tiers, and it did until October 16, when the first contention run used up the Neon project's transfer allowance. I moved the project to Neon's Launch plan the same day. It has no monthly minimum; it charges $0.106 per CU-hour and $0.35 per GB-month of storage, and includes 500 GB of transfer a month, per Neon's pricing page. The second contention run moved 56 MB of it. Neon is now the first line in Bullpen's cost ledger that can be more than zero.

What I could read myself, as of October 16:

ResourceUsedAllowance
Neon storage12 MB1 GB free; $0.35 per GB-month on Launch
Neon network transfer5 GB free plan used up, then 56 MB500 GB a month on Launch
Alpha Vantage calls0 in production, 4 on previews at peak20 a day, 5 on previews
CoinGecko calls14 in October, all previews10,000 a month

Neon's billing page showed no charges yet. Compute hours and the Vercel numbers live on dashboards that I copy into the repository's cost ledger after each week closes, so they are not in this table, and I have not estimated them. Vercel, CoinGecko and Alpha Vantage are still on free plans.

Transaction invariants set the hard boundaries

Put authoritative data where the transaction ends, not where the free tier ends. A shared budget, a deploy need, a failure domain or a measured load has to justify every other split, and the load is measured in bytes as well as queries. Part 4 tests that against the order lifecycle, where one transaction is no longer enough.

Read next

Still here? You might enjoy this.

Nothing close enough — try a different angle?

Architecting Software in 2026 · 3 of 6

Part 4 is in progress.

New parts land on Mondays, 9am Pacific — leave an address and I'll send each one the day it ships. Nothing else.

Was this helpful?

Leave a rating or a quick note — it helps me improve.

Related Posts

Engineering

Architecture as Code Has Three Jobs: Describe, Govern, Remember

In Bullpen's first week, the planned architecture model still named a stock provider I had turned down, and a footer change and a test followed it. Nothing failed. I use that incident and two others to argue that architecture as code has three jobs: describe the system, govern it, and remember why it looks the way it does. Then I follow the first real change after the fixes, product analytics, through all three: a decision record before the model could name it, built and planned architecture that disagreed, and a rule that composes the two instead of editing either.

Engineering

When to Use Microservices in 2026: Scaling Alone Is No Longer the Reason

On the day the Dow posted a then-record gain, Robinhood's customers could not trade from the open to the close. Splitting out the hot part so it scales alone was the old answer; a serverless platform now does that per function. I open this series with Bullpen, a paper-trading league on live market data, and argue when to use microservices in 2026: only for a reason I can name, with a check, a measurement or a bill behind it.

Engineering

The Shift Zone: The Backend Is Becoming Software That Sleeps

I have watched state break the monolith, then microservices, then serverless — and I have spent months reading the changelogs that convince me the third break just got fixed. These are my notes on the 2024–2026 convergence of ephemeral compute and durable state, why the billing model is the tell, and the A/B experiment this post commits me to: rebuilding a problem I already solved in Spring Boot on the new substrate, with the numbers published either way.