Why this matters
The microservices movement created two popular myths. The first myth: services are inherently independent. This is false — if two services share a database schema and one service's migration breaks the other's queries, they are coupled regardless of what protocol separates them. The second myth: services define architecture. This is also false — architecture is defined by the Dependency Rule applied to source code, not by whether communication happens in-process or over HTTP.
A monolith with excellent internal boundaries — clean layers, abstract interfaces, proper DTOs — can be more maintainable than a fleet of microservices that share a database, pass raw SQL rows between them via REST, and require synchronized deployments. The communication protocol (function call vs HTTP vs message queue) is a detail. The structural question is the same: do the inner circles depend on the outer circles?
True decoupling in microservices comes from one rule: each service owns its own data. Services communicate through published event contracts, not shared tables. An event is a stable, versioned fact about something that happened. It crosses the service boundary as a simple DTO. The receiving service decides what to do with it — independently.
The problem
Three "microservices" that share the same database table — a distributed monolith where one schema change breaks all three simultaneously.
Bad
# service_auth — queries shared "users" table
def get_user_for_login(email: str) -> dict:
conn = psycopg2.connect("postgresql://localhost/shared_db") # shared DB!
cur.execute("SELECT id, email, password_hash FROM users WHERE email = %s", (email,))
# service_profile — same table, different columns
def get_user_profile(user_id: str) -> dict:
conn = psycopg2.connect("postgresql://localhost/shared_db") # same table!
cur.execute("SELECT id, display_name, avatar_url FROM users WHERE id = %s", (user_id,))
# service_billing — same table again
def get_billing_contact(user_id: str) -> dict:
conn = psycopg2.connect("postgresql://localhost/shared_db") # same table again!
cur.execute("SELECT id, email, billing_address FROM users WHERE id = %s", (user_id,))
# Rename "email" to "contact_email" in the DB:
# All three services break. Three deployments must be synchronized.
# This is a distributed monolith — complexity of distribution, zero independence.
// All three services import from the same shared DB type:
// type UserRow = { id: string; email: string; password_hash: string;
// display_name: string; avatar_url: string; billing_address: string }
// service-auth
const user = await db.query("SELECT id, email, password_hash FROM users WHERE email=$1", [email]);
// service-profile
const profile = await db.query("SELECT id, display_name, avatar_url FROM users WHERE id=$1", [id]);
// service-billing
const contact = await db.query("SELECT id, email, billing_address FROM users WHERE id=$1", [id]);
// ALTER TABLE users RENAME email TO contact_email;
// → Three services fail. Three teams must coordinate a synchronized deployment.
The solution
Each service owns its own data store. They communicate through a published event contract — a stable, versioned DTO. A schema change in one service is invisible to the others.
Good
from dataclasses import dataclass
@dataclass
class UserRegisteredEvent:
user_id: str
email: str # minimal contract — only what both sides agree on
# service_auth — owns auth_db.credentials, publishes event on registration
def register_user(email: str, password: str) -> str:
user_id = auth_repo.save_credentials(email, hash_password(password))
event_bus.publish(UserRegisteredEvent(user_id=user_id, email=email))
return user_id
# service_profile — subscribes, owns its own profile_db.profiles
def on_user_registered(event: UserRegisteredEvent) -> None:
profile_repo.create_profile(user_id=event.user_id, display_name=event.email)
# service_billing — subscribes, owns its own billing_db.contacts
def on_user_registered(event: UserRegisteredEvent) -> None:
billing_repo.create_contact(user_id=event.user_id, billing_email=event.email)
# Auth changes its credentials schema: Profile and Billing are unaffected.
# Each service is independently deployable and changeable.
// Stable, versioned event contract
export interface UserRegisteredEvent {
readonly userId: string;
readonly email: string;
}
// service-auth: owns its own DB, publishes event
export class AuthService {
async register(email: string, password: string): Promise<string> {
const userId = await this.credentialsRepo.save({ email, passwordHash: hash(password) });
await this.eventBus.publish<UserRegisteredEvent>("user.registered", { userId, email });
return userId;
}
}
// service-profile: subscribes, owns its own DB
export class ProfileEventHandler {
async onUserRegistered(event: UserRegisteredEvent): Promise<void> {
await this.profileRepo.create({ userId: event.userId, displayName: event.email });
}
}
// service-billing: subscribes, owns its own DB
export class BillingEventHandler {
async onUserRegistered(event: UserRegisteredEvent): Promise<void> {
await this.billingRepo.create({ userId: event.userId, billingEmail: event.email });
}
}
// Auth schema change: Profile and Billing are unaffected. True independence.
Key takeaway
If your microservices share a database schema, you have a distributed monolith — all the complexity of distribution with none of the independence. True decoupling requires each service to own its own data and communicate through stable event contracts, not shared tables. Architecture is the Dependency Rule; services are just a deployment detail.