Skip to content
Joey Wang
Menu

Search

Full Stack Development

Shared Databases Between Microservices Are a Code Smell

When two services share one database, a schema change in either one can silently break the other, and here is how to split ownership with APIs or events.

· 2 min read

engineering #microservices#database#architecture

Audio summary

When you build microservices, it’s common to start simple: two apps reading and writing the same database tables. It works right up until it doesn’t. A schema change in one service can silently break the other, and neither team can point to a document that says who owns what.

The problem: two apps, one database

Take two Python services. App A serves an API to users. App B processes SQS messages and writes to the database. Both read and write the same tables, through their own, separately maintained ORM models:

# app_a/models/user.py
class User(Base):
    __tablename__ = 'users'
    id = Column(Integer, primary_key=True)
    name = Column(String)
    status = Column(String)

# app_b/models/user.py (duplicated)
class User(Base):
    __tablename__ = 'users'
    id = Column(Integer, primary_key=True)
    name = Column(String)
    status = Column(String)

Both definitions are correct today. The risk is drift: someone adds a column in App B’s migration and forgets App A has its own copy of this model. There’s no contract between the two, just an assumption that the schema stays in sync.

The cost shows up gradually: a schema change in B can silently break A, integration tests get fragile because there’s no boundary to mock, and both services compete for the same connections and locks under load.

Clean service boundaries

Extract the shared model into a library

Move the ORM definitions into one package both services import:

shared_models/
  db.py
  models/
    user.py
from shared_models.models.user import User

This removes the duplication. It does not remove the coupling: both services still touch the same tables directly.

Define ownership

TableOwned byAccessed by
usersApp BApp A (read-only)
messagesApp BApp B only

Each service should write only to tables it owns. Everything else goes through a view, a read-only role, or an API.

Move to an API boundary

Instead of App A reading the database directly, it calls App B:

App A -> App B API -> App B DB
@app.get("/users/{user_id}")
def get_user(user_id: int):
    user = session.query(User).filter_by(id=user_id).first()
    return user

Now App B can change its schema freely as long as the endpoint’s contract holds.

Or move to events

App B -> publishes "user.created" -> App A subscribes

App B owns the data and publishes what changed; App A builds its own read model from the events it cares about. This is more work to set up than an API call, but it decouples the two services from each other’s uptime, not just their schema.

When a shared database is still fine

Early in a project, or inside a monorepo where one team owns both services, sharing a database is a reasonable shortcut. It stops being reasonable once two teams, two deploy schedules, or two on-call rotations are involved. If you take the shortcut, keep three things in place: a shared model library so the schema is defined once, an explicit ownership table like the one above, and contract tests that fail when one side changes the shape of data the other side depends on.

The point of the API or event boundary isn’t purity. It’s being able to change one service without reading the other service’s code first.