Monday, 5 October 2026

The Database Is Often the Hardest Part of Service Extraction

The Database Is Often the Hardest Part of Service Extraction

When people talk about extracting a service from a monolith, the conversation often focuses on code.

Move the controller.

Move the business logic.

Create an API.

Deploy it separately.

Done.

Except the difficult part is usually not the code.

It is the data.

You can move code in a day. Untangling data ownership can take months.

That is why I think service extraction should begin with the database question much earlier than most teams expect.

A service is not really independent if the database is still shared

Imagine a modular monolith with:

Customers
Orders
Billing
Reporting
Notifications

Now Billing is extracted into its own service.

The architecture diagram suddenly looks cleaner:

Main Application
      |
      └── Billing Service

But Billing still directly reads and writes:

customers
orders
payments
subscriptions
invoices

inside the same database used by the monolith.

Technically, you now have two deployable processes.

Architecturally, you may still have one tightly coupled system.

The coupling simply moved across a network.

Data ownership matters more than table location

One of the most useful questions during extraction is:

Who is the authoritative writer for this data?

Consider an order.

The Orders capability may own:

Order status
Order items
Order totals
Fulfilment state

Billing may own:

Payment attempts
Payment status
Refunds
Invoices
Provider references

That gives us clearer ownership:

Orders owns order state

Billing owns payment state

Now they communicate through contracts.

That is much stronger than both services casually updating the same rows.

Shared databases make change deceptively easy

A shared database is convenient.

Suppose Billing needs the customer's email.

The easiest thing to do is:

SELECT email
FROM customers
WHERE id = ?

It works.

Then Billing needs the shipping country.

Another query.

Then customer tier.

Another join.

Soon Billing depends directly on the internal shape of Customers.

Now imagine the Customers team wants to restructure its tables.

They cannot change them safely without understanding everything Billing queries.

The database schema has quietly become an API.

Except unlike a normal API, it often has no explicit versioning or ownership boundary.

Cross-service joins are a warning sign

In a monolith, this is easy:

SELECT ...
FROM orders
JOIN customers ...
JOIN payments ...
JOIN subscriptions ...

Once those capabilities become separate services, that assumption changes.

You now have to decide:

  • should one service request the data from another?
  • should it maintain a local projection?
  • should the data be copied through events?
  • should reporting own a separate read model?
  • does the operation need all of this data synchronously?

This is where service extraction stops being a directory restructuring exercise.

It becomes architecture.

Transactions become harder too

Inside one database, we can do:

BEGIN

Create order
Reserve stock
Create payment record

COMMIT

The database gives us atomicity.

Everything succeeds, or everything rolls back.

Now move Billing to another service.

The workflow becomes:

Orders DB
   ↓
Network
   ↓
Billing DB

There is no ordinary local database transaction covering both anymore.

That means new questions appear.

What if the order succeeds but payment creation fails?

What if payment succeeds but the response times out?

What if the message is delivered twice?

What if the second service is temporarily unavailable?

These are not edge cases.

They are part of distributed systems.

Timeout does not mean failure

This is one of the most important lessons in service communication.

Imagine Orders calls Billing:

POST /payments

Billing charges the card successfully.

But the network connection dies before Orders receives the response.

Orders sees:

timeout

Did the payment fail?

Not necessarily.

Maybe it succeeded.

That means automatically retrying can be dangerous.

Without idempotency, you might charge the customer twice.

The database boundary changes the meaning of failure.

In a local transaction, failure may mean rollback.

Across services, failure may mean uncertainty.

That is a much harder problem.

Eventual consistency becomes real

Suppose Orders emits:

OrderPlaced

Billing consumes it and creates payment state.

Reporting consumes it and updates analytics.

Notifications consumes it and sends confirmation.

For a short period:

Orders knows about the order

Billing may not yet know

Reporting may not yet know

Notifications may still be waiting

That is eventual consistency.

It can be perfectly acceptable.

But the product and code need to understand it.

If the UI assumes every service has updated instantly, you will create confusing behavior.

Distributed architecture requires thinking about time.

Sometimes duplicating data is correct

Developers are often taught:

“Never duplicate data.”

That advice makes sense inside one normalized relational model.

Across service boundaries, selective duplication can be useful.

Billing might keep:

customer_id
customer_email_snapshot
billing_country

for a transaction.

Reporting might maintain its own projection of Orders.

Search may maintain an index derived from several capabilities.

The important question becomes:

Who owns the truth, and who owns a copy?

Copies can be rebuilt.

The authoritative source cannot be ambiguous.

Reporting is where shared data often becomes obvious

Reporting frequently exposes the limitations of strict service ownership.

A report may need:

Customer
+
Order
+
Payment
+
Subscription
+
Refund

Calling five services every time someone opens a dashboard is rarely ideal.

This is why mature systems often develop separate analytical models:

Operational services
       ↓
Events / replication
       ↓
Reporting model

Reporting becomes a consumer of business data rather than a service that reaches directly into everyone else's database.

That is often a healthier boundary.

Migration makes this even harder

Now imagine extracting Billing from a ten-year-old application.

The existing database already contains:

millions of payments
old invoice formats
legacy status values
historical corrections
provider identifiers
manual administrative changes

You cannot simply create a new database and pretend the history does not exist.

You need a migration strategy.

That might involve:

Backfill
Dual reads
Temporary synchronization
Cutover
Reconciliation
Rollback

And one question matters throughout:

Which system is allowed to write?

Two uncontrolled authoritative writers are dangerous.

One authoritative writer simplifies the transition

Suppose legacy Billing currently owns payment data.

During migration:

Legacy App
    |
    | authoritative writer
    ↓
Existing DB

New Billing Service
    |
    └── reads replicated/migrated data

Eventually ownership changes:

New Billing Service
    |
    | authoritative writer
    ↓
Billing DB

The old application may still read that data temporarily.

But write ownership has a clear transition.

That is much safer than letting both sides update payment state indefinitely.

Service extraction should begin with ownership questions

Before extracting a capability, I would ask:

What data does this capability own?

Which data does it only read?

Who writes each record today?

Who should write it after extraction?

Which transactions cross the boundary?

Which joins cross the boundary?

What consistency does the business actually require?

What happens during partial failure?

Can operations be made idempotent?

How will historical data move?

What is the rollback plan?

If those questions do not have good answers yet, the service boundary may not be ready.

This is why I prefer modularity before distribution

Inside a modular monolith, we can practice ownership before introducing network boundaries.

For example:

Orders module
    owns order data

Billing module
    owns payment data

Orders cannot modify Billing tables directly

Everything may still live in one database initially.

That is fine.

The important part is establishing logical ownership.

Later, if Billing genuinely needs independent deployment, the conceptual boundary already exists.

The physical database split becomes much easier.

Extraction is a data problem disguised as an application problem

Moving PHP classes is usually the easy part.

The difficult parts are:

  • ownership,
  • historical data,
  • transactions,
  • consistency,
  • synchronization,
  • failure recovery,
  • cutover,
  • rollback.

That is why I would not judge a service extraction by whether the code runs in another container.

I would ask:

Can this capability own its data and remain correct when the rest of the system is temporarily unavailable?

If the answer is no, the extraction may be premature.

Because a service boundary becomes real when responsibility becomes independent.

And responsibility begins with who owns the data.

No comments:

Post a Comment