The case for a modular monolith in an early-stage product

Use a reproducible order-and-outbox example to see which failures a local transaction prevents, and which still need retry and recovery decisions.
Start with the operation that must commit together
I prefer a modular monolith when one team changes ordering and balance reservation together and the product requires both changes to commit or neither to happen. The reason is the transaction boundary. Moving Billing behind an HTTP endpoint makes a successful order write and a failed reservation possible unless the workflow handles partial completion.
That preference has limits. A workload processing untrusted files may need isolation from the web application. A separately operated component may require its own release schedule or resource allocation. I recommend choosing a service when independent operation addresses a concrete constraint, then budgeting for failure recovery as part of that choice.
The example below uses synthetic orders and a local integer balance, not payments or customer data. It tests database state and duplicate handling. It provides no throughput comparison and does not show that a monolith is universally cheaper or faster.
Keep the business change and publish intent in one transaction
The producer creates an order, reserves its amount from a wallet and inserts an outbox row inside BEGIN IMMEDIATE and COMMIT. If the injected failure occurs before the outbox insert, ROLLBACK removes the order and restores the balance. Reopening the database verifies that no event remains available to the relay.
The fixture opens SQLite connections with Python autocommit=True and controls these transactions using explicit SQL. This is deliberate: Python connection.commit() has no effect in that mode. The downloadable setup creates the tables and invokes the complete module shown below.
A normal order uses a fictional amount of 30 against a balance of 100. Reconnecting observes one committed order, a balance of 70 and one pending outbox event. Those values demonstrate one transaction, not pricing, account reconciliation or a production financial ledger.
"""Single relay, synthetic delivery; local SQL effects only."""
def place_order(db, order_id, amount, checkpoint=lambda stage: None):
db.execute("BEGIN IMMEDIATE")
try:
db.execute("INSERT INTO orders VALUES (?, ?)", (order_id, amount))
changed = db.execute(
"UPDATE wallet SET balance = balance - ? WHERE id = 'demo' AND balance >= ?",
(amount, amount),
).rowcount
if changed != 1:
raise ValueError("insufficient balance")
checkpoint("before_outbox")
db.execute("INSERT INTO outbox(event_id, order_id, amount) VALUES (?, ?, ?)",
("order-created:" + order_id, order_id, amount))
db.execute("COMMIT")
except BaseException:
if db.in_transaction:
db.execute("ROLLBACK")
raise
def relay_once(db, send, checkpoint=lambda stage: None):
row = db.execute(
"SELECT event_id, order_id, amount FROM outbox WHERE sent = 0 ORDER BY rowid LIMIT 1"
).fetchone()
if row is None:
return False
event = dict(zip(("event_id", "order_id", "amount"), row))
send(event)
checkpoint("after_send")
db.execute("UPDATE outbox SET sent = 1 WHERE event_id = ?", (event["event_id"],))
return True
def consume(db, event, checkpoint=lambda stage: None):
if (not isinstance(event, dict) or set(event) != {"event_id", "order_id", "amount"}
or any(not isinstance(event[key], str) or not 1 <= len(event[key]) <= 200
for key in ("event_id", "order_id"))
or type(event["amount"]) is not int or not 1 <= event["amount"] <= 1_000_000):
raise ValueError("invalid event")
db.execute("BEGIN IMMEDIATE")
try:
inserted = db.execute(
"INSERT INTO processed VALUES (?, ?, ?) ON CONFLICT(event_id) DO NOTHING",
(event["event_id"], event["order_id"], event["amount"]),
).rowcount
if inserted == 0:
previous = db.execute(
"SELECT order_id, amount FROM processed WHERE event_id = ?",
(event["event_id"],),
).fetchone()
if previous != (event["order_id"], event["amount"]):
raise ValueError("event ID reused with different payload")
db.execute("COMMIT")
return False
checkpoint("after_dedup")
db.execute("INSERT INTO notifications(order_id, amount) VALUES (?, ?)",
(event["order_id"], event["amount"]))
db.execute("COMMIT")
return True
except BaseException:
if db.in_transaction:
db.execute("ROLLBACK")
raise
A successful delivery can still be retried
The relay reads a pending row, passes its event to the synthetic delivery function and marks the row sent. The test injects a failure after delivery but before that final update. The message list already contains the event, while the outbox row remains pending. Running the relay again appends the same event a second time.
The event identifier stays order-created:order-104 across both deliveries. The consumer records that identifier, its payload and its local notification row in one transaction. A repeated delivery with the same payload returns without another local effect. Reusing the identifier with a different order or amount raises an error instead of silently discarding the conflict. AWS describes both the dual-write problem addressed by an outbox and the need for consumers to handle duplicate delivery.
This relay runs sequentially. It does not claim safe coordination of multiple publishers, durable broker acknowledgements, global ordering or message-schema compatibility. Those concerns belong in the design before independently deployed workers use the same outbox.
The consumer accepts exactly three fields, bounded string identifiers and a positive integer amount up to a demo limit of 1,000,000. This shape check does not authenticate a sender or authorize an order. The producer is called with fixed trusted fixture inputs; adding a public order endpoint would require its own validation and authorization.
Keep the deduplication record with the effect it protects
The consumer test fails after inserting the processed marker but before inserting the notification. Because both writes share a transaction, reconnecting sees neither new write. The next delivery can apply the effect. A deliberate version that commits the marker early fails this check: it can remember an event without performing the work.
The notifications table represents a local SQL effect. It does not send email. To expose that distinction, a separate test appends a synthetic delivery to a Python list inside the callback, then fails. Rolling back SQL does not remove the list entry. Retrying appends a second delivery even though the final local database effect appears only once.
I recommend checking the external provider’s idempotency contract before promising duplicate-free notifications. Otherwise choose an explicit reconciliation or duplicate-tolerance policy. Recording processed before sending can lose an action after a crash; recording it afterwards can repeat an action after an ambiguous result.
Use the failure costs to decide what to extract
The downloadable fixture passes 46 named observations and rejects four deliberate defects: committing producer state before the outbox write, skipping consumer deduplication, committing the consumer marker before its effect and ignoring a reused identifier with different contents. All use the same pinned runtime. Failures are injected exceptions, and reconnection checks database state; this is not a power-loss, concurrent-worker or real email test.
I recommend keeping Ordering and Billing in one deployment while their shared transaction is useful and their changes remain coordinated. Give each module a small application interface and keep internal persistence private. A separate worker process can move expensive background work out of web requests while retaining the same codebase and ownership.
Before extracting Billing, I recommend agreeing who resolves an abandoned reservation and how retries identify the same operation. Test an unavailable service and a response lost after a successful write. Name the operational owner. Those decisions show what the new service boundary costs; an architecture checklist or a headcount threshold cannot answer them.
Reproduce the examples
The exact Python module, SQLite schema, 46 observations, four deliberate defects and a pinned Docker runner. Synthetic delivery only; no broker or credentials.
Download the source and tests (.zip)
