Post

Share Db Structure

Designing Microservices with Proper Data Boundaries: Why Shared Databases Are a Code Smell

When building microservices, itโ€™s common to start with simplicity: multiple services reading and writing to the same database. But over time, this creates tight coupling, fragile integrations, and hidden data contracts. In this article, we explore why sharing databases across services is a code smell, and how to improve or evolve your architecture with clean boundaries, APIs, and events.


๐Ÿ“Š The Problem: Shared Database Between Services

Imagine two Python applications:

  • App A exposes API endpoints to serve data to users
  • App B processes SQS messages and updates the database

Both read and write to the same database tables, using duplicated ORM models.

Whatโ€™s Wrong with This?

IssueDescription
๐Ÿ”„ Tight couplingA schema change in B can silently break A
๐ŸŒ€ Hidden contractsNo formal API or expectations between A and B
๐Ÿ˜“ Migration frictionDB schema changes are risky and disruptive
๐Ÿค– Testing challengesIntegration testing becomes fragile
๐Ÿ“ˆ Scalability limitationsShared load and contention on the same DB

Code Smell Example

1
2
3
4
5
6
7
8
9
10
11
12
13
# 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)

Even if both are correct today, any future drift will break assumptions in subtle ways.


๐Ÿ’ผ Solution: Clean Service Boundaries

Step 1: Extract Shared ORM into a Library

Move ORM model definitions to a shared package:

1
2
3
4
shared_models/
  db.py
  models/
    user.py

Now both services import from the same source:

1
from shared_models.models.user import User

This reduces duplication and ensures consistency.


Step 2: Define Ownership and Access Rules

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

Each service should only write to its own tables, or use views/roles to enforce read-only access.


Step 3: Evolve Toward API Boundaries

Instead of reading directly from the DB, App A can call App B via API:

1
[ App A ] -> [ App B API ] -> [ App B DB ]

This creates an explicit contract and allows App B to evolve internally.

Sample FastAPI Endpoint (App B)

1
2
3
4
@app.get("/users/{user_id}")
def get_user(user_id: int):
    user = session.query(User).filter_by(id=user_id).first()
    return user

Step 4: Consider an Event-Driven Architecture

Use events for communication instead of shared DBs:

1
[ App B ] -> publishes "user.created" -> [ App A subscribes ]
  • B owns the truth and publishes events
  • A builds its own read model from events

This allows true decoupling and better scalability.


โœ… When Shared DB Is Acceptable

If youโ€™re early in development or working within a monorepo, shared DB access can be a temporary convenience. Just follow these safeguards:

  • Use a shared model library
  • Define clear table ownership
  • Write contract-level integration tests

๐Ÿš€ Final Thoughts

If your microservices communicate via a shared DB, itโ€™s a sign to reevaluate boundaries. Move toward clear contracts, APIs, and events โ€” youโ€™ll reduce fragility, increase team autonomy, and improve scalability long-term.

Need help refactoring your services or designing an event-driven layer? Reach out and letโ€™s chat!

This post is licensed under CC BY 4.0 by the author.