Skip to content
Aug 14, 2026·8 min read

Monolith vs microservices through the database

Treat monolith vs microservices as a data ownership decision: map transactions, expose shared tables, and split only where boundaries hold.

Monolith vs microservices through the database

A service boundary is credible only when the data on each side can change without a coordinated database commit. If two pieces of code must lock the same rows, update the same tables, or agree on the same deployment window, drawing an HTTP line between them has changed the transport, not the architecture.

That is why the useful version of monolith vs microservices starts with the schema. Code can be moved behind an endpoint in an afternoon. Ownership, invariants, historical data, reporting queries, retries, and failure recovery stay attached to the tables. Teams that start with classes and packages usually discover this after they have built a distributed monolith: more network calls, more operational work, and the same database coupling underneath.

I do not object to microservices. I object to pretending that a process boundary creates a data boundary. The schema tells you where the system already behaves as one unit, where it merely shares storage by convenience, and where a split could survive a bad deploy at 2 a.m.

A transaction map is more useful than a dependency graph

Map every business operation to the rows it reads and writes before choosing service boundaries. A code dependency graph shows which module calls another. It does not show that posting an invoice, reserving stock, and writing an audit entry must either all happen or all fail. The database knows that fact because those writes share a transaction.

Start with operations, not tables. For each command that changes state, record the initiating actor, tables read, tables written, locks taken, constraints relied upon, and the consequence of partial completion. Include jobs, triggers, stored procedures, file imports, and operator scripts. They are often where the alleged boundary disappears.

A compact transaction map might record Place order as reading customer, product, and stock while writing order, order line, and stock. Its invariant says stock cannot fall below zero, and its partial failure leaves an accepted order that cannot be fulfilled. Capture payment reads the order and prior attempts, then writes the payment, ledger entry, and order state under a one-capture invariant. Cancel order touches shipment, payment, stock, refund, and order records because shipped goods need a return path.

This map exposes two different kinds of coupling. Transactional coupling means writes must commit together to preserve an invariant. Read coupling means one operation consults data owned elsewhere. The first can block a split. The second often needs a replica, an event-fed projection, or an explicit request, but it does not automatically require shared ownership. Teams blur these two and then either keep everything together forever or distribute a transaction that never needed distribution.

Trace production queries as well as source code. Dynamic SQL, ORM-generated statements, nightly jobs, and stored procedure calls can evade static analysis. A table with no obvious application references may still feed month-end close. If deleting a module would make an accountant's report wrong three days later, that module is not isolated.

Shared tables are deferred coordination costs

A shared table lets two services ship quickly by making the database their private integration API. The invoice service inserts a row, the reporting service reads it directly, and the fulfillment service adds a status column. Nothing looks expensive until one team needs to change a column, reinterpret a value, backfill old rows, or restore from backup. Then every reader becomes part of the change.

The cost is not that two processes can technically query one table. The cost is ambiguous authority. Which service may add a constraint? Who decides whether status = 4 means packed or dispatched? Which deployment owns the backfill? Who restores the table if one service needs point-in-time recovery but another has already written newer state? Shared storage turns ordinary schema work into cross-team scheduling.

Foreign keys deserve a precise distinction. A foreign key inside one ownership boundary is useful executable documentation. A foreign key across proposed service boundaries means the database still enforces a cross-service invariant. Dropping it does not remove the invariant; it merely transfers detection to application code and makes bad references possible. Keep the systems together until you can state what replaces that guarantee.

This ownership query is a practical first pass in PostgreSQL:

SELECT table_schema, table_name,
       array_agg(DISTINCT application_name ORDER BY application_name) AS writers
FROM audit_statement_usage
WHERE command IN ('INSERT', 'UPDATE', 'DELETE')
GROUP BY table_schema, table_name
HAVING count(DISTINCT application_name) > 1
ORDER BY table_schema, table_name;

PostgreSQL does not provide audit_statement_usage as a built-in table. Create it from database audit logs or statement telemetry with at least application_name, command, schema, and table. Its output shape is the point: any row with multiple writers needs an ownership decision, not an endpoint. Do not infer ownership from database roles alone if several applications share credentials, a habit that makes the evidence useless.

A healthy target has one authoritative writer for each table. Other components may receive copies designed for their own queries. A copy has a freshness contract and can be rebuilt. A shared table has multiple parties quietly depending on its current shape, which is a much harder contract to see.

The consistency requirement chooses the boundary

Keep data in one transactional boundary when the business cannot tolerate an observable intermediate state. Separate it when delayed agreement is acceptable and you can define how the delay is repaired. This is a product decision expressed in database behavior, not a preference for synchronous or asynchronous code.

Payment and ledger entries make the issue plain. If the system can record a captured payment without the corresponding ledger entry, even briefly, another job may refund it, settle it, or report it incorrectly. You can place those writes in separate services, but then you need an explicit protocol for atomic intent, retries, deduplication, and reconciliation. The network has made a local invariant into a distributed workflow. That may be justified, but it is not free decoupling.

The popular recommendation to put a message broker between everything is wrong when it comes before the consistency decision. A broker moves messages reliably under stated conditions. It does not decide whether a reservation may lag an order, what to do when an event arrives twice, or who repairs a missing projection. Those are application semantics. Hiding them behind publish() gives the team less evidence, not more.

Ask four concrete questions for every proposed boundary:

  1. What state can users or jobs observe between the two commits?
  2. How long may that state remain inconsistent?
  3. Which side retries, and how does the receiver recognize a duplicate?
  4. What process detects and repairs a message that never produced the intended state?

If the answer to the second question is effectively zero, prefer one transaction unless regulatory isolation, scale, or organizational ownership provides a stronger reason to accept distributed coordination. If the answer is seconds or minutes, write that limit down and monitor it. The word eventual is not a service-level objective.

One database does not mean one owner

You can establish real service ownership inside one database by separating schemas, roles, migrations, and write privileges. Conversely, separate database servers can remain tightly coupled when services coordinate every release and make synchronous calls to reconstruct old joins. Physical separation is evidence of a boundary only when operational independence follows it.

A useful transitional layout gives each component a schema and a login that can write only there. Cross-schema reads are temporary, inventoried exceptions. The permission model turns accidental writes into visible failures:

REVOKE ALL ON SCHEMA billing FROM fulfillment_app;
GRANT USAGE ON SCHEMA billing TO fulfillment_app;
GRANT SELECT ON billing.invoice_summary TO fulfillment_app;
REVOKE INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA billing FROM fulfillment_app;

Grant access to a purpose-built view such as invoice_summary, not every billing table. The view gives the owner a small compatibility surface while consumers move to an API or local projection. Record each grant with an owner and removal condition. Otherwise the temporary bridge becomes the next shared database.

Database-per-service is also frequently misunderstood. It means a service owns its persistence contract and other services cannot reach around it. It does not require a separate database cluster for every small process. Separate logical databases may help with restore boundaries and privileges, while schemas in one PostgreSQL instance may be enough during extraction. Choose the isolation that enforces ownership without multiplying operational work before the boundary has proved itself.

Cross-boundary joins need a named replacement before extraction. A local join can filter, sort, and paginate against one consistent snapshot. Turning it into several API calls may fetch thousands of records, create an N-plus-one request pattern, and combine results observed at different times. The endpoint can still return correct JSON in a unit test while behaving badly under real cardinality.

Choose the replacement according to the query. A screen that needs one current billing status may make a direct request with a timeout and a defined fallback. A search page that filters orders by customer attributes usually needs a local projection designed for that filter. An offline report belongs in an analytical store. Copying selected facts is deliberate data duplication; making live calls until a distributed join happens accidentally is hidden coupling.

Pagination exposes a common failure. Suppose the caller requests the first 50 orders sorted by customer risk, but order and risk data now have different owners. Fetching 50 orders and then looking up risk cannot produce the correct top 50. Fetching more orders is a guess with unstable performance. Either move the ranking into an owned read model or change the product contract. Network fan-out cannot preserve database semantics by optimism.

Set freshness according to the decision the query supports. A fraud hold may require current status and fail closed when its owner is unavailable. A sales dashboard may accept data that trails production by several minutes. Put the observed version or update time in the projection so callers can enforce that rule. Without provenance, cached data looks current even when the feed stopped hours ago.

Do not split a database solely because one table is large. Partitioning, indexing, archiving, and workload isolation solve storage and query problems more directly. A service boundary earns its cost when it separates change authority or failure behavior. Size by itself says little about either.

Events need an atomic source of truth

Retire the shared-table monolith
CodeHero rewrites legacy systems into Go services and Postgres without transliterating their old structure.

Use a transactional outbox when a committed database change must reliably produce an event. Writing business data and then publishing to a broker creates a gap: the commit can succeed and the publish can fail. Publishing first creates the reverse gap. A distributed transaction can close it, but it increases operational coupling and is rarely supported cleanly across the systems involved.

The outbox places the business write and an event record in the same local commit:

BEGIN;
UPDATE orders
SET status = 'confirmed', version = version + 1
WHERE id = :order_id AND status = 'pending';

INSERT INTO outbox_event (event_id, aggregate_id, event_type, payload, created_at)
VALUES (:event_id, :order_id, 'order.confirmed', :payload, CURRENT_TIMESTAMP);
COMMIT;

A relay publishes unsent rows and marks progress. It may publish an event more than once if it crashes after sending but before recording success, so consumers still need idempotency. Store a stable event ID and make the consumer's state change conditional on that ID not having been processed. Exactly-once claims often reduce to at-least-once delivery plus deduplicated effects. Say which one you actually provide.

Order matters too. A single global sequence limits throughput and creates false coupling, while no ordering rule lets a cancellation overtake a confirmation. Order events per aggregate where the business needs it, include an aggregate version, and reject or park gaps. The repair path must be boring enough for an operator to run without inventing state.

Change data capture can feed an outbox relay, but raw table change capture is not a substitute for domain events. A row update says storage changed. It does not explain whether an order was confirmed, corrected, imported, or repaired. Consumers that reverse-engineer intent from columns become coupled to the schema you were trying to free.

Reporting exposes ownership that commands can hide

Operational service boundaries rarely match analytical questions, so reporting should combine owned copies rather than regain direct access to every production table. A customer profitability report may need orders, refunds, support costs, and ledger data. Making one reporting service synchronously call four operational services recreates a distributed join with worse latency and failure modes.

Build a reporting store from events, change feeds, or scheduled extracts, and give it explicit freshness and reconciliation rules. It can denormalize aggressively because it does not own operational truth. When a definition changes, rebuild the projection from retained facts or a controlled snapshot rather than asking each source to preserve every historical query shape.

Beware the shared customer table. Identity, billing party, shipping recipient, account login, and legal counterparty often start as one row and diverge as the business grows. A universal customer service can become a dependency for almost every request. Define which facts each domain owns and which identifiers connect them. Duplication of a customer's display name is often cheaper than synchronous availability of a central profile service.

Reconciliation makes eventual consistency accountable. Compare source counts and monetary totals by stable business keys, track the oldest unprocessed event, and keep a replay procedure. A green broker dashboard does not prove that the reporting rows match the ledger. The check must compare business outcomes, not transport activity.

For regulated environments, copies also affect retention, access, and deletion duties. A projection is still data. Inventory where sensitive fields flow, minimize what each consumer receives, and prove deletion or retention behavior across replicas. Separate storage does not make governance disappear.

A safe extraction moves ownership before traffic

Rewrite around real data boundaries
CodeHero maps the whole legacy tree and rebuilds its architecture around owned Postgres data.

Move one business capability by establishing its data contract, shadowing behavior, and transferring write authority before routing all production traffic. Extracting code first leaves the new service dependent on old tables, so the riskiest part arrives late and under schedule pressure.

A reliable sequence is:

  1. Choose a capability with one plausible owner and a tolerable consistency boundary. Inventory every writer and reader of its tables.
  2. Put characterization tests around current behavior, including failures, retries, rounding, null handling, and operator overrides. Capture representative production requests only under the organization's privacy rules.
  3. Introduce the future schema and backfill it with repeatable, checkpointed jobs. Dual-read in shadow mode and compare results without serving them.
  4. Move to one authoritative write path. If a temporary dual write is unavoidable, log both outcomes and run a reconciler; do not assume two independent writes stay equal.
  5. Route reads and then traffic to the new owner, keep a tested rollback path, and remove old grants only after lag and parity remain within the agreed limits.

The awkward part is backfill plus live change. A snapshot starts at one time while production keeps moving. Use a database-consistent snapshot and a change position, then apply later changes in order. Make the backfill idempotent so rerunning a range does not duplicate or regress rows. Record rejected rows with enough context to repair them; a migration that silently skips malformed legacy data creates a cleaner schema and a false business record.

Avoid bidirectional synchronization. It looks like a safe rollback mechanism, but conflict rules soon become a second application. During the cutover, name one authority for each field at each phase. Rollback should reverse routing and replay a known log, not invite both systems to edit the same fact.

A strangler facade can help route callers, but it does not solve data ownership. If the facade sends new requests to a service that still updates the monolith's tables, you have changed deployment topology only. Measure progress by retired writers, removed cross-schema grants, and eliminated coordinated migrations.

Parity tests must compare business effects

Test a rewrite by comparing observable behavior and resulting state, not by checking that endpoints return similar status codes. Legacy systems contain rules in triggers, scheduled jobs, default values, string truncation, collation, time zones, and manual repair routines. A clean rewrite can be logically attractive and still alter invoices or inventory.

Build a parity harness that feeds the same recorded or synthetic request to old and new paths, normalizes permitted differences, and compares responses plus database effects. For a command, compare rows created, money posted, events emitted, and error classification. For a query, compare ordering, pagination, null behavior, and permission filtering. Mask sensitive values before they enter the harness and retain the comparison result as migration evidence.

Define tolerances field by field. Timestamps may differ within a stated window. Generated identifiers may differ but preserve referential relationships. Decimal money should usually match exactly after the declared rounding rule. A blanket JSON diff produces noise until engineers start ignoring it, which defeats the test.

Run fault cases, not only happy paths. Kill the relay after publish, retry a timed-out request, deliver events out of order, restore a database snapshot, and deploy an old consumer against a new event version. The boundary is credible when these failures have bounded effects and a documented repair, not when a diagram looks tidy.

This is where CodeHero's approach to legacy rewrites is relevant: it reads the whole codebase, modernizes the architecture, and checks behavior with a parity harness against recorded production traffic. The under-30-day delivery promise would be reckless without treating database effects as part of behavior rather than translating source files one by one.

Compatible schema changes buy deployment independence

Replace the release train
CodeHero turns legacy web and desktop monoliths into owned Go services and TypeScript clients.

A boundary is not independently deployable if every schema change requires all producers and consumers to switch in the same release. Design database and event changes so old and new versions can overlap for a defined period. That overlap lets a team deploy, observe, and roll back without calling every other owner into the release window.

Use expand and contract for fields that cross a boundary. First add the new column or event field without removing the old one. Teach writers to populate the new representation, backfill historical rows, and make readers prefer it while accepting the old form. Verify that no old reader remains, then stop producing the old field and remove it in a later change. Each phase needs a measurable exit condition, such as zero reads of the old column for a full operational cycle, rather than a calendar guess.

Renaming a column in place is attractive because the final schema looks clean immediately. It also breaks old binaries, forgotten jobs, and rollback deployments at once. Add the new name, copy data under one authority, and contract later. The extra column is temporary complexity with a retirement plan; a coordinated outage is operational complexity without one.

Event schemas need the same discipline. Consumers should ignore fields they do not understand, producers should not change the meaning of an existing field, and required fields should have a valid introduction path. Version the semantic contract, not every harmless addition. Publishing order.cancelled.v2 for each new optional attribute fills the system with conversion code while failing to protect against the dangerous change, which is redefining what cancelled means.

Database views can provide a short compatibility window for renamed or reshaped reads. They are poor permanent APIs when consumers depend on undocumented joins or query plans. Put an expiry condition on every compatibility view and observe who still queries it. Removing a view based on a repository search alone misses ad hoc reporting and binaries that no longer live in the main build.

Rollback determines whether the compatibility plan is honest. If the new application writes a value that the old version cannot parse, rolling back the binary does not restore service. Test old code against state produced by the new code, including enum values, nullability, longer strings, and event variants. Backward compatible DDL is only half the problem; stored data must remain readable throughout the rollback window.

Independent deployment is therefore an evidence claim. Show that either application version works with either compatible schema phase, that the backfill can resume, and that contract telemetry identifies remaining consumers. If that matrix does not pass, the service boundary still shares a release train even if the repositories and databases have different names.

Some monoliths should stay monoliths

Keep a monolith when one team changes it coherently, its transactions match business invariants, deployment risk is controlled, and scaling does not require independent placement. A modular monolith with enforced package and schema ownership can offer most of the organizational clarity people seek from services without network failure modes or fleet overhead.

The strongest reason to split is independent change authority backed by a real data boundary. Other sound reasons include isolation of a failure-prone workload, a distinct security perimeter, or compute needs that demand different scaling. None of these excuses an undefined consistency model, but they can justify its cost.

Do not use team size formulas to decide. Two teams can collaborate well in one repository, while one team can create a miserable collection of tiny services. Team topology matters because ownership and communication shape design, but the database still enforces the facts. If every release requires coordinated migrations, the organization has not gained independent delivery.

Before approving a split, require a short boundary record that names the owned tables, authoritative writer, cross-boundary reads, consistency window, event contract, restore unit, migration sequence, and rollback authority. Reject the proposal if any answer is both services or eventually without a limit and repair process. That standard prevents more damage than arguing about the ideal number of services.

Treat restore and disaster recovery as boundary tests too. If one service cannot restore its data without rewinding another service, they share an operational unit. If replay after restore requires undocumented edits to foreign tables, the proposed ownership model is incomplete. Practice the restore with queued events and downstream projections present. Confirm that consumers can reject stale replays and rebuild their copies without direct writes into the recovered schema, then record which side decides the recovery point and how later writes return.

The first useful change is often smaller than a service extraction: stop shared credentials, log writers by application, assign every table an owner, and make cross-owner access visible. Once the schema shows honest boundaries, the code can follow. Where the schema refuses to separate, listen to it.

FAQ

Should each microservice have its own database?

Each microservice should own its persistence and prevent other services from writing around its contract. That can mean separate databases, but schemas and roles in one database may enforce enough isolation during a migration.

Is a shared database always bad for microservices?

A shared server is not automatically bad; shared write authority is. If services own separate schemas and access each other through explicit read contracts, one server can be a practical transitional or permanent arrangement.

How do I find transaction boundaries in a monolith?

Trace each business command to every row it reads and writes, including triggers, jobs, stored procedures, and operator scripts. Group the changes that must commit together to keep a business invariant true.

Can an API remove database coupling?

An API hides a schema, but it does not remove coupling when callers still need coordinated commits, releases, or recovery. The boundary improves only when the provider owns the data and callers can tolerate its availability and consistency contract.

When should I use eventual consistency?

Use it when the business accepts a named period of disagreement and the team has retry, deduplication, monitoring, and repair procedures. If the acceptable delay is zero, a local transaction is usually the clearer design.

What is the safest way to split a shared table?

Choose one authoritative writer, create an owned target schema, backfill from a consistent position, and apply live changes after that position. Shadow reads and reconciliation should prove parity before consumers switch.

Does a message broker solve distributed transactions?

No. A broker transports messages, but your application still defines partial states, duplicate handling, ordering, and repair. A transactional outbox closes the gap between a local commit and event creation, while consumers still need idempotency.

How should reports work after a monolith is split?

Feed a separate reporting store with events, change feeds, or controlled extracts. Give it freshness and reconciliation rules instead of letting it query every operational database or synchronously join services.

What should a microservice rollback include?

A rollback must cover routing, schema compatibility, queued events, and writes accepted during the failed release. Keep one authority per field and replay a known log; bidirectional writes make rollback conflicts harder.

Is a modular monolith better than microservices?

It is better when transactions, team ownership, and deployment still move together. Enforced modules and schema permissions can create clear ownership without adding network calls, distributed recovery, and separate operations for every component.